* Refresh Russian localization bundle * feat(#5682): honor profile query during boot * fix(profile): refresh deep-link reasoning state (#5682) * test(ci): initialize reasoning transition harness state * test(ci): accept reasoning fetch context override * fix(#5682): preserve retryable profile query intent * test(#5682): accept profile switch result contract * fix(#5682): bind reasoning fetches to profile transitions * Release exp-v0.52.42: ?profile= deep links (#5730) + Russian locale refresh (#5778) --------- Co-authored-by: DrMaks22 <DrMaks22@users.noreply.github.com> Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
833 lines
34 KiB
Python
833 lines
34 KiB
Python
"""Regression tests for #5682 profile query boot switching."""
|
|
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"
|
|
PANELS_JS_PATH = REPO_ROOT / "static" / "panels.js"
|
|
UI_JS_PATH = REPO_ROOT / "static" / "ui.js"
|
|
SESSIONS_JS = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
|
BOOT_JS = BOOT_JS_PATH.read_text(encoding="utf-8")
|
|
PANELS_JS = PANELS_JS_PATH.read_text(encoding="utf-8")
|
|
UI_JS = UI_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_valid_profile_query_switches_before_restore_and_cleans_url():
|
|
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/' };
|
|
console.warn = (...args) => { throw new Error('unexpected warn: ' + args.join(' ')); };
|
|
applyUrl('/app/?profile=vops&q=hello&prompt=hi&send=1&keep=1#frag');
|
|
global.localStorage = {
|
|
store: { 'hermes-webui-session': 'saved-local' },
|
|
getItem(key) {
|
|
return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null;
|
|
},
|
|
setItem(key, value) {
|
|
this.store[key] = String(value);
|
|
},
|
|
removeItem(key) {
|
|
delete this.store[key];
|
|
}
|
|
};
|
|
evalSession('_profileQueryIntentFromLocation');
|
|
evalSession('_consumeProfileQueryParamFromLocation');
|
|
evalSession('_consumeComposerPrefillParamsFromLocation');
|
|
evalSession('_sessionUrlForSid');
|
|
evalBoot('_profileQueryBlocksSavedLocalRestore');
|
|
const intent = _profileQueryIntentFromLocation();
|
|
global.S = { activeProfile: 'default', activeProfileIsDefault: true };
|
|
const switched = [];
|
|
global.switchToProfile = async (name) => {
|
|
switched.push(name);
|
|
S.activeProfile = name;
|
|
S.activeProfileIsDefault = false;
|
|
localStorage.setItem('hermes-webui-session', 'fresh-local');
|
|
return true;
|
|
};
|
|
(async () => {
|
|
const savedLocalBefore = localStorage.getItem('hermes-webui-session');
|
|
const profileSwitchProfileBefore = S.activeProfile || 'default';
|
|
const profileSwitchIsDefaultBefore = !!S.activeProfileIsDefault;
|
|
let profileSwitchCompleted = false;
|
|
let profileSwitchChangedProfile = false;
|
|
if (intent && intent.hasParam) {
|
|
try {
|
|
if (intent.valid) {
|
|
if (typeof switchToProfile === 'function') {
|
|
profileSwitchCompleted = await switchToProfile(intent.name) === true;
|
|
if (profileSwitchCompleted) {
|
|
profileSwitchChangedProfile = (S.activeProfile || 'default') !== profileSwitchProfileBefore || !!S.activeProfileIsDefault !== profileSwitchIsDefaultBefore;
|
|
if (typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation();
|
|
}
|
|
}
|
|
} else {
|
|
console.warn('[boot] ignored invalid profile query', intent.name);
|
|
}
|
|
} catch (e) {
|
|
console.warn('[boot] profile query switch failed', e);
|
|
}
|
|
}
|
|
const blocksSavedLocal = _profileQueryBlocksSavedLocalRestore(intent, null);
|
|
if (blocksSavedLocal && profileSwitchCompleted && profileSwitchChangedProfile && localStorage.getItem('hermes-webui-session') === savedLocalBefore) localStorage.removeItem('hermes-webui-session');
|
|
const savedLocalAfterSuppress = localStorage.getItem('hermes-webui-session');
|
|
const savedLocalAfterReload = localStorage.getItem('hermes-webui-session');
|
|
const keepsExplicitSession = _profileQueryBlocksSavedLocalRestore(intent, 'session-123');
|
|
const afterProfile = window.location.pathname + window.location.search + window.location.hash;
|
|
const promoted = _sessionUrlForSid('abc 123');
|
|
_consumeComposerPrefillParamsFromLocation();
|
|
const afterPrefill = window.location.pathname + window.location.search + window.location.hash;
|
|
const profilePos = bootSrc.indexOf("const profileIntent=(typeof _profileQueryIntentFromLocation==='function')?_profileQueryIntentFromLocation():null;");
|
|
const renderPos = bootSrc.indexOf("await renderSessionList();", profilePos);
|
|
const savedPos = bootSrc.indexOf("const saved=urlSession||savedLocal;", profilePos);
|
|
const loadPos = bootSrc.indexOf("await loadSession(saved, {preserveActiveInput:true});", profilePos);
|
|
const consumePos = bootSrc.indexOf("if(typeof _consumeProfileQueryParamFromLocation==='function') _consumeProfileQueryParamFromLocation();", profilePos);
|
|
const completedPos = bootSrc.indexOf("_profileSwitchCompleted=await switchToProfile(profileIntent.name)===true;", profilePos);
|
|
const changedPos = bootSrc.indexOf("_profileSwitchChangedProfile=", completedPos);
|
|
const cleanupGuardPos = bootSrc.indexOf("if(_profileQueryBlocksSavedLocal&&_profileSwitchCompleted&&_profileSwitchChangedProfile){", profilePos);
|
|
const initialReasoningFetchPos = bootSrc.indexOf("if(typeof fetchReasoningChip==='function'&&(!_profileSwitchCompleted||!_profileSwitchChangedProfile)) fetchReasoningChip();", profilePos);
|
|
console.log(JSON.stringify({ intent, switched, promoted, afterProfile, afterPrefill, historyCalls: window.history.calls, profilePos, renderPos, savedPos, loadPos, consumePos, completedPos, changedPos, cleanupGuardPos, initialReasoningFetchPos, savedLocalBefore, savedLocalAfterSuppress, savedLocalAfterReload, blocksSavedLocal, keepsExplicitSession }));
|
|
})().catch(err => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
payload = json.loads(_run_node(source))
|
|
assert payload["intent"] == {"hasParam": True, "valid": True, "name": "vops"}
|
|
assert payload["switched"] == ["vops"]
|
|
assert payload["promoted"] == "/app/session/abc%20123?keep=1#frag"
|
|
assert payload["afterProfile"] == "/app/?q=hello&prompt=hi&send=1&keep=1#frag"
|
|
assert payload["afterPrefill"] == "/app/?keep=1#frag"
|
|
assert payload["historyCalls"][0]["url"] == "/app/?q=hello&prompt=hi&send=1&keep=1#frag"
|
|
assert payload["historyCalls"][1]["url"] == "/app/?keep=1#frag"
|
|
assert payload["profilePos"] >= 0
|
|
assert payload["renderPos"] > payload["profilePos"]
|
|
assert payload["savedPos"] > payload["profilePos"]
|
|
assert payload["loadPos"] > payload["savedPos"]
|
|
assert payload["consumePos"] > payload["profilePos"]
|
|
assert payload["completedPos"] > payload["profilePos"]
|
|
assert payload["changedPos"] > payload["completedPos"]
|
|
assert payload["cleanupGuardPos"] > payload["consumePos"]
|
|
assert payload["initialReasoningFetchPos"] > payload["completedPos"]
|
|
assert payload["savedLocalBefore"] == "saved-local"
|
|
assert payload["savedLocalAfterSuppress"] == "fresh-local"
|
|
assert payload["savedLocalAfterReload"] == "fresh-local"
|
|
assert payload["blocksSavedLocal"] is True
|
|
assert payload["keepsExplicitSession"] is False
|
|
|
|
|
|
def test_noop_profile_query_switch_keeps_saved_local_state():
|
|
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/?profile=default&q=hello&keep=1#frag');
|
|
global.S = { activeProfile: 'default', activeProfileIsDefault: true };
|
|
global.localStorage = {
|
|
store: { 'hermes-webui-session': 'saved-local' },
|
|
getItem(key) {
|
|
return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null;
|
|
},
|
|
removeItem(key) {
|
|
delete this.store[key];
|
|
}
|
|
};
|
|
evalSession('_profileQueryIntentFromLocation');
|
|
evalSession('_consumeProfileQueryParamFromLocation');
|
|
evalSession('_consumeComposerPrefillParamsFromLocation');
|
|
evalSession('_sessionUrlForSid');
|
|
evalBoot('_profileQueryBlocksSavedLocalRestore');
|
|
const intent = _profileQueryIntentFromLocation();
|
|
global.switchToProfile = async () => true;
|
|
(async () => {
|
|
const savedLocalBefore = localStorage.getItem('hermes-webui-session');
|
|
const profileSwitchProfileBefore = S.activeProfile || 'default';
|
|
const profileSwitchIsDefaultBefore = !!S.activeProfileIsDefault;
|
|
let profileSwitchCompleted = false;
|
|
let profileSwitchChangedProfile = false;
|
|
if (intent && intent.hasParam) {
|
|
try {
|
|
if (intent.valid) {
|
|
if (typeof switchToProfile === 'function') {
|
|
profileSwitchCompleted = await switchToProfile(intent.name) === true;
|
|
if (profileSwitchCompleted) {
|
|
profileSwitchChangedProfile = (S.activeProfile || 'default') !== profileSwitchProfileBefore || !!S.activeProfileIsDefault !== profileSwitchIsDefaultBefore;
|
|
if (typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation();
|
|
}
|
|
}
|
|
} else {
|
|
console.warn('[boot] ignored invalid profile query', intent.name);
|
|
}
|
|
} catch (e) {
|
|
console.warn('[boot] profile query switch failed', e);
|
|
}
|
|
}
|
|
const blocksSavedLocal = _profileQueryBlocksSavedLocalRestore(intent, null);
|
|
if (blocksSavedLocal && profileSwitchCompleted && profileSwitchChangedProfile && localStorage.getItem('hermes-webui-session') === savedLocalBefore) localStorage.removeItem('hermes-webui-session');
|
|
const cleanupGuardPos = bootSrc.indexOf("if(_profileQueryBlocksSavedLocal&&_profileSwitchCompleted&&_profileSwitchChangedProfile){", bootSrc.indexOf("const profileIntent=(typeof _profileQueryIntentFromLocation==='function')?_profileQueryIntentFromLocation():null;"));
|
|
console.log(JSON.stringify({
|
|
intent,
|
|
blocksSavedLocal,
|
|
profileSwitchCompleted,
|
|
profileSwitchChangedProfile,
|
|
savedLocalBefore,
|
|
savedLocalAfter: localStorage.getItem('hermes-webui-session'),
|
|
cleanupGuardPos,
|
|
}));
|
|
})().catch(err => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
payload = json.loads(_run_node(source))
|
|
assert payload["intent"] == {"hasParam": True, "valid": True, "name": "default"}
|
|
assert payload["blocksSavedLocal"] is True
|
|
assert payload["profileSwitchCompleted"] is True
|
|
assert payload["profileSwitchChangedProfile"] is False
|
|
assert payload["savedLocalBefore"] == "saved-local"
|
|
assert payload["savedLocalAfter"] == "saved-local"
|
|
assert payload["cleanupGuardPos"] >= 0
|
|
|
|
|
|
def test_failed_profile_query_switch_keeps_saved_local_state():
|
|
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: null,
|
|
calls: [],
|
|
replaceState(state, title, url) {
|
|
this.calls.push({ state, title, url });
|
|
this.state = state;
|
|
applyUrl(url);
|
|
}
|
|
}
|
|
};
|
|
global.document = { baseURI: 'https://example.test/app/' };
|
|
const warns = [];
|
|
console.warn = (...args) => { warns.push(args.map(String)); };
|
|
applyUrl('/app/?profile=vops&q=hello&keep=1#frag');
|
|
global.localStorage = {
|
|
store: { 'hermes-webui-session': 'saved-local' },
|
|
getItem(key) {
|
|
return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null;
|
|
},
|
|
removeItem(key) {
|
|
delete this.store[key];
|
|
}
|
|
};
|
|
evalSession('_profileQueryIntentFromLocation');
|
|
evalSession('_consumeProfileQueryParamFromLocation');
|
|
evalSession('_consumeComposerPrefillParamsFromLocation');
|
|
evalSession('_sessionUrlForSid');
|
|
evalBoot('_profileQueryBlocksSavedLocalRestore');
|
|
const intent = _profileQueryIntentFromLocation();
|
|
global.switchToProfile = async () => { throw new Error('boom'); };
|
|
(async () => {
|
|
const savedLocalBefore = localStorage.getItem('hermes-webui-session');
|
|
let profileSwitchCompleted = false;
|
|
if (intent && intent.hasParam) {
|
|
try {
|
|
if (intent.valid) {
|
|
if (typeof switchToProfile === 'function') {
|
|
profileSwitchCompleted = await switchToProfile(intent.name) === true;
|
|
if (profileSwitchCompleted && typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation();
|
|
}
|
|
} else {
|
|
console.warn('[boot] ignored invalid profile query', intent.name);
|
|
if (typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation();
|
|
}
|
|
} catch (e) {
|
|
console.warn('[boot] profile query switch failed', e);
|
|
}
|
|
}
|
|
const blocksSavedLocal = _profileQueryBlocksSavedLocalRestore(intent, null);
|
|
if (blocksSavedLocal && profileSwitchCompleted && localStorage.getItem('hermes-webui-session') === savedLocalBefore) localStorage.removeItem('hermes-webui-session');
|
|
const afterBoot = window.location.pathname + window.location.search + window.location.hash;
|
|
_consumeComposerPrefillParamsFromLocation();
|
|
const afterPrefill = window.location.pathname + window.location.search + window.location.hash;
|
|
const promoted = _sessionUrlForSid('abc 123');
|
|
console.log(JSON.stringify({
|
|
blocksSavedLocal,
|
|
profileSwitchCompleted,
|
|
savedLocalAfter: localStorage.getItem('hermes-webui-session'),
|
|
afterBoot,
|
|
afterPrefill,
|
|
promoted,
|
|
warns,
|
|
}));
|
|
})().catch(err => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
payload = json.loads(_run_node(source))
|
|
assert payload["blocksSavedLocal"] is True
|
|
assert payload["profileSwitchCompleted"] is False
|
|
assert payload["savedLocalAfter"] == "saved-local"
|
|
assert payload["afterBoot"] == "/app/?profile=vops&q=hello&keep=1#frag"
|
|
assert payload["afterPrefill"] == "/app/?profile=vops&keep=1#frag"
|
|
assert payload["promoted"] == "/app/session/abc%20123?profile=vops&keep=1#frag"
|
|
assert payload["warns"] == [["[boot] profile query switch failed", "Error: boom"]]
|
|
|
|
|
|
def test_unsuccessful_profile_query_result_keeps_retry_url_without_warning():
|
|
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: null,
|
|
calls: [],
|
|
replaceState(state, title, url) {
|
|
this.calls.push({ state, title, url });
|
|
this.state = state;
|
|
applyUrl(url);
|
|
}
|
|
}
|
|
};
|
|
global.document = { baseURI: 'https://example.test/app/' };
|
|
const warns = [];
|
|
console.warn = (...args) => { warns.push(args.map(String)); };
|
|
applyUrl('/app/?profile=vops&q=hello&keep=1#frag');
|
|
global.localStorage = {
|
|
store: { 'hermes-webui-session': 'saved-local' },
|
|
getItem(key) {
|
|
return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null;
|
|
},
|
|
removeItem(key) {
|
|
delete this.store[key];
|
|
}
|
|
};
|
|
evalSession('_profileQueryIntentFromLocation');
|
|
evalSession('_consumeProfileQueryParamFromLocation');
|
|
evalBoot('_profileQueryBlocksSavedLocalRestore');
|
|
const intent = _profileQueryIntentFromLocation();
|
|
global.switchToProfile = async () => false;
|
|
(async () => {
|
|
const savedLocalBefore = localStorage.getItem('hermes-webui-session');
|
|
let profileSwitchCompleted = false;
|
|
if (intent && intent.hasParam) {
|
|
try {
|
|
if (intent.valid) {
|
|
if (typeof switchToProfile === 'function') {
|
|
profileSwitchCompleted = await switchToProfile(intent.name) === true;
|
|
if (profileSwitchCompleted && typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation();
|
|
}
|
|
} else {
|
|
console.warn('[boot] ignored invalid profile query', intent.name);
|
|
if (typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation();
|
|
}
|
|
} catch (e) {
|
|
console.warn('[boot] profile query switch failed', e);
|
|
}
|
|
}
|
|
const blocksSavedLocal = _profileQueryBlocksSavedLocalRestore(intent, null);
|
|
if (blocksSavedLocal && profileSwitchCompleted && localStorage.getItem('hermes-webui-session') === savedLocalBefore) localStorage.removeItem('hermes-webui-session');
|
|
console.log(JSON.stringify({
|
|
blocksSavedLocal,
|
|
profileSwitchCompleted,
|
|
savedLocalAfter: localStorage.getItem('hermes-webui-session'),
|
|
url: window.location.pathname + window.location.search + window.location.hash,
|
|
historyCalls: window.history.calls,
|
|
warns,
|
|
}));
|
|
})().catch(err => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
payload = json.loads(_run_node(source))
|
|
assert payload["blocksSavedLocal"] is True
|
|
assert payload["profileSwitchCompleted"] is False
|
|
assert payload["savedLocalAfter"] == "saved-local"
|
|
assert payload["url"] == "/app/?profile=vops&q=hello&keep=1#frag"
|
|
assert payload["historyCalls"] == []
|
|
assert payload["warns"] == []
|
|
|
|
|
|
def test_invalid_profile_query_warns_and_skips_switch():
|
|
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: null,
|
|
calls: [],
|
|
replaceState(state, title, url) {
|
|
this.calls.push({ state, title, url });
|
|
this.state = state;
|
|
applyUrl(url);
|
|
}
|
|
}
|
|
};
|
|
global.document = { baseURI: 'https://example.test/app/' };
|
|
const warns = [];
|
|
console.warn = (...args) => { warns.push(args); };
|
|
applyUrl('/app/?profile=../bad&q=hello&keep=1#frag');
|
|
evalSession('_profileQueryIntentFromLocation');
|
|
evalSession('_consumeProfileQueryParamFromLocation');
|
|
const intent = _profileQueryIntentFromLocation();
|
|
const switched = [];
|
|
global.switchToProfile = async (name) => { switched.push(name); };
|
|
(async () => {
|
|
if (intent && intent.hasParam) {
|
|
try {
|
|
if (intent.valid) {
|
|
if (typeof switchToProfile === 'function') await switchToProfile(intent.name);
|
|
} else {
|
|
console.warn('[boot] ignored invalid profile query', intent.name);
|
|
if (typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation();
|
|
}
|
|
} catch (e) {
|
|
console.warn('[boot] profile query switch failed', e);
|
|
}
|
|
}
|
|
console.log(JSON.stringify({
|
|
intent,
|
|
switched,
|
|
warns,
|
|
url: window.location.pathname + window.location.search + window.location.hash,
|
|
historyCalls: window.history.calls,
|
|
}));
|
|
})().catch(err => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
payload = json.loads(_run_node(source))
|
|
assert payload["intent"] == {"hasParam": True, "valid": False, "name": "../bad"}
|
|
assert payload["switched"] == []
|
|
assert payload["warns"] == [["[boot] ignored invalid profile query", "../bad"]]
|
|
assert payload["url"] == "/app/?q=hello&keep=1#frag"
|
|
assert payload["historyCalls"] == [{"state": None, "title": "", "url": "/app/?q=hello&keep=1#frag"}]
|
|
|
|
|
|
def test_prefill_cleanup_still_strips_q_prompt_and_send():
|
|
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: null,
|
|
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=hi&send=1&keep=1#frag');
|
|
evalSession('_consumeComposerPrefillParamsFromLocation');
|
|
_consumeComposerPrefillParamsFromLocation();
|
|
console.log(JSON.stringify({
|
|
url: window.location.pathname + window.location.search + window.location.hash,
|
|
historyCalls: window.history.calls,
|
|
}));
|
|
"""
|
|
payload = json.loads(_run_node(source))
|
|
assert payload["url"] == "/app/?keep=1#frag"
|
|
assert payload["historyCalls"] == [{"state": None, "title": "", "url": "/app/?keep=1#frag"}]
|
|
|
|
|
|
def test_profile_query_blocks_only_implicit_saved_local_restore():
|
|
source = _node_prelude() + """
|
|
evalBoot('_profileQueryBlocksSavedLocalRestore');
|
|
global.localStorage = {
|
|
store: { 'hermes-webui-session': 'saved-local' },
|
|
getItem(key) {
|
|
return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null;
|
|
},
|
|
setItem(key, value) {
|
|
this.store[key] = String(value);
|
|
},
|
|
removeItem(key) {
|
|
delete this.store[key];
|
|
}
|
|
};
|
|
const validProfile = { hasParam: true, valid: true };
|
|
const invalidProfile = { hasParam: true, valid: false };
|
|
const blocksImplicit = _profileQueryBlocksSavedLocalRestore(validProfile, null);
|
|
if (blocksImplicit) localStorage.removeItem('hermes-webui-session');
|
|
const implicitAfter = localStorage.getItem('hermes-webui-session');
|
|
localStorage.setItem('hermes-webui-session', 'saved-local');
|
|
const allowsExplicit = _profileQueryBlocksSavedLocalRestore(validProfile, 'session-123');
|
|
if (allowsExplicit) localStorage.removeItem('hermes-webui-session');
|
|
const explicitAfter = localStorage.getItem('hermes-webui-session');
|
|
console.log(JSON.stringify({
|
|
blocksImplicit,
|
|
allowsExplicit,
|
|
ignoresInvalid: _profileQueryBlocksSavedLocalRestore(invalidProfile, null),
|
|
implicitAfter,
|
|
explicitAfter,
|
|
}));
|
|
"""
|
|
payload = json.loads(_run_node(source))
|
|
assert payload == {
|
|
"blocksImplicit": True,
|
|
"allowsExplicit": False,
|
|
"ignoresInvalid": False,
|
|
"implicitAfter": None,
|
|
"explicitAfter": "saved-local",
|
|
}
|
|
|
|
|
|
def test_profile_transition_reasoning_refresh_hides_stale_chip_until_destination_resolves():
|
|
source = f"""
|
|
const uiSrc = {UI_JS!r};
|
|
function extractFunc(name) {{
|
|
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
|
const start = uiSrc.search(re);
|
|
if (start < 0) throw new Error(name + ' not found');
|
|
let i = uiSrc.indexOf('{{', start), depth = 1; i++;
|
|
while (depth > 0 && i < uiSrc.length) {{
|
|
if (uiSrc[i] === '{{') depth++;
|
|
else if (uiSrc[i] === '}}') depth--;
|
|
i++;
|
|
}}
|
|
return uiSrc.slice(start, i);
|
|
}}
|
|
function makeEl() {{
|
|
return {{
|
|
style: {{}}, classList: {{ toggle(){{}}, add(){{}}, remove(){{}} }},
|
|
setAttribute(){{}}, querySelectorAll(){{ return []; }},
|
|
}};
|
|
}}
|
|
const els = {{
|
|
composerReasoningWrap: makeEl(), composerReasoningLabel: makeEl(),
|
|
composerReasoningChip: makeEl(), composerMobileReasoningAction: makeEl(),
|
|
}};
|
|
global.$ = id => els[id] || null;
|
|
global.S = {{ session: {{ model: 'gpt-5', model_provider: 'openai' }} }};
|
|
global._highlightReasoningOption = () => {{}};
|
|
global._applyReasoningOptions = () => {{}};
|
|
global._reasoningEffortQuery = () => '?model=gpt-5';
|
|
const pending = [];
|
|
let calls = 0;
|
|
global.api = () => {{
|
|
calls++;
|
|
return {{ then(ok) {{ pending.push(ok); return {{ catch() {{}} }}; }} }};
|
|
}};
|
|
var _currentReasoningEffort = 'low';
|
|
var _currentReasoningEffortsSupported = ['low', 'high'];
|
|
var _lastReasoningFetchKey = '?model=gpt-5';
|
|
var _reasoningFetchSeq = 7;
|
|
eval(extractFunc('_normalizeReasoningEffort'));
|
|
eval(extractFunc('_formatReasoningEffortLabel'));
|
|
eval(extractFunc('_applyReasoningChip'));
|
|
eval(extractFunc('fetchReasoningChip'));
|
|
eval(extractFunc('refreshProfileTransitionReasoningChip'));
|
|
fetchReasoningChip();
|
|
refreshProfileTransitionReasoningChip();
|
|
const beforeDestination = {{
|
|
effort: _currentReasoningEffort,
|
|
cachedKey: _lastReasoningFetchKey,
|
|
wrapDisplay: els.composerReasoningWrap.style.display,
|
|
calls,
|
|
}};
|
|
pending[0]({{ reasoning_effort: 'low', supported_efforts: ['low', 'high'] }});
|
|
const afterPreviousResponse = {{ effort: _currentReasoningEffort, wrapDisplay: els.composerReasoningWrap.style.display }};
|
|
pending[1]({{ reasoning_effort: 'high', supported_efforts: ['low', 'high'] }});
|
|
console.log(JSON.stringify({{ beforeDestination, afterPreviousResponse, afterDestination: _currentReasoningEffort, calls }}));
|
|
"""
|
|
payload = json.loads(_run_node(source))
|
|
assert payload["beforeDestination"] == {
|
|
"effort": "",
|
|
"cachedKey": "?model=gpt-5",
|
|
"wrapDisplay": "none",
|
|
"calls": 2,
|
|
}
|
|
assert payload["afterPreviousResponse"] == {"effort": "", "wrapDisplay": "none"}
|
|
assert payload["afterDestination"] == "high"
|
|
assert payload["calls"] == 2
|
|
assert "refreshProfileTransitionReasoningChip" in PANELS_JS
|
|
assert PANELS_JS.index("refreshProfileTransitionReasoningChip") > PANELS_JS.index("S.activeProfile = data.active || name")
|
|
background = PANELS_JS[PANELS_JS.index("function _refreshProfileSwitchBackground"):PANELS_JS.index("async function loadProfilesPanel")]
|
|
for refresh in (
|
|
"_ensureComposerControlVisibilityState",
|
|
"_setComposerControlOrder",
|
|
"_renderComposerControlChips",
|
|
"_renderComposerSituationalControlChips",
|
|
"_applyComposerFooterVisibilitySettings",
|
|
"_applyTitlebarProfileVisibility",
|
|
):
|
|
assert refresh in background
|
|
|
|
|
|
def test_profile_transitions_fetch_destination_reasoning_after_hiding_stale_chip():
|
|
source = f"""
|
|
const uiSrc = {UI_JS!r};
|
|
const panelsSrc = {PANELS_JS!r};
|
|
const sessionsSrc = {SESSIONS_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), 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 makeEl() {{ return {{ style: {{}}, disabled: false, classList: {{ add(){{}}, remove(){{}}, toggle(){{}} }}, setAttribute(){{}}, querySelectorAll(){{ return []; }} }}; }}
|
|
const els = {{ composerReasoningWrap: makeEl(), composerReasoningLabel: makeEl(), composerReasoningChip: makeEl(), composerMobileReasoningAction: makeEl() }};
|
|
global.$ = id => els[id] || null;
|
|
global.window = {{}};
|
|
global.document = {{ title: '' }};
|
|
global.localStorage = {{ removeItem(){{}} }};
|
|
global.S = {{ activeProfile: 'default', activeProfileIsDefault: true, session: null, messages: [] }};
|
|
global._highlightReasoningOption = () => {{}};
|
|
global._applyReasoningOptions = () => {{}};
|
|
global._applyModelToDropdown = model => model;
|
|
global._modelStateForSelect = (_, model) => ({{ model, model_provider: 'openai' }});
|
|
global.renderSessionList = async () => {{}};
|
|
global.startGatewaySSE = () => {{}};
|
|
global.showToast = () => {{}};
|
|
global.t = value => value;
|
|
global.assistantDisplayName = () => 'Hermes';
|
|
global._profileSwitchPanelLoad = async () => {{}};
|
|
global._refreshProfileSwitchBackground = () => {{}};
|
|
var _profileSwitchGeneration = 0;
|
|
var _skillsData = null, _workspaceList = null;
|
|
var _currentReasoningEffort = 'low';
|
|
var _currentReasoningEffortsSupported = ['low', 'high'];
|
|
var _profileTransitionReasoningContext = null;
|
|
var _lastReasoningFetchKey = null;
|
|
var _reasoningFetchSeq = 0;
|
|
eval(extractFunc(uiSrc, '_normalizeReasoningEffort'));
|
|
eval(extractFunc(uiSrc, '_formatReasoningEffortLabel'));
|
|
eval(extractFunc(uiSrc, '_reasoningEffortContext'));
|
|
eval(extractFunc(uiSrc, '_reasoningEffortQuery'));
|
|
eval(extractFunc(uiSrc, '_applyReasoningChip'));
|
|
eval(extractFunc(uiSrc, 'fetchReasoningChip'));
|
|
eval(extractFunc(uiSrc, 'refreshProfileTransitionReasoningChip'));
|
|
eval(extractFunc(uiSrc, 'syncTopbar'));
|
|
eval(extractFunc(panelsSrc, 'switchToProfile'));
|
|
eval(extractFunc(sessionsSrc, '_switchProfileForSessionLoad'));
|
|
const pending = [];
|
|
const reasoningUrls = [];
|
|
global.api = (url) => {{
|
|
if (url === '/api/profile/switch') return Promise.resolve({{ active: 'vops', is_default: false, default_model: 'gpt-high', default_model_provider: 'openai' }});
|
|
if (url.startsWith('/api/reasoning')) {{
|
|
reasoningUrls.push(url);
|
|
return {{ then(ok) {{ pending.push(ok); return {{ catch() {{}} }}; }} }};
|
|
}}
|
|
throw new Error('unexpected API ' + url);
|
|
}};
|
|
fetchReasoningChip();
|
|
(async () => {{
|
|
await switchToProfile('vops');
|
|
const blankBoot = {{ hidden: els.composerReasoningWrap.style.display, urls: reasoningUrls.slice() }};
|
|
pending[0]({{ reasoning_effort: 'low', supported_efforts: ['low', 'high'] }});
|
|
const blankBootAfterOld = _currentReasoningEffort;
|
|
pending[1]({{ reasoning_effort: 'high', supported_efforts: ['low', 'high'] }});
|
|
const blankBootAfterNew = _currentReasoningEffort;
|
|
S.activeProfile = 'default'; S.activeProfileIsDefault = true;
|
|
S.session = {{ model: 'old-model', model_provider: 'old-provider', profile: 'default' }};
|
|
_currentReasoningEffort = 'low'; _currentReasoningEffortsSupported = ['low', 'high']; _profileTransitionReasoningContext = null; _lastReasoningFetchKey = null;
|
|
fetchReasoningChip();
|
|
await _switchProfileForSessionLoad('vops');
|
|
const directLoad = {{ hidden: els.composerReasoningWrap.style.display, urls: reasoningUrls.slice(2) }};
|
|
pending[2]({{ reasoning_effort: 'low', supported_efforts: ['low', 'high'] }});
|
|
const directLoadAfterOld = _currentReasoningEffort;
|
|
pending[3]({{ reasoning_effort: 'high', supported_efforts: ['low', 'high'] }});
|
|
console.log(JSON.stringify({{ blankBoot, blankBootAfterOld, blankBootAfterNew, directLoad, directLoadAfterOld, directLoadAfterNew: _currentReasoningEffort }}));
|
|
}})().catch(err => {{ console.error(err); process.exit(1); }});
|
|
"""
|
|
payload = json.loads(_run_node(source))
|
|
assert payload["blankBoot"] == {
|
|
"hidden": "none",
|
|
"urls": ["/api/reasoning", "/api/reasoning?model=gpt-high&provider=openai"],
|
|
}
|
|
assert payload["blankBootAfterOld"] == ""
|
|
assert payload["blankBootAfterNew"] == "high"
|
|
assert payload["directLoad"] == {
|
|
"hidden": "none",
|
|
"urls": ["/api/reasoning?model=old-model&provider=old-provider", "/api/reasoning?model=gpt-high&provider=openai"],
|
|
}
|
|
assert payload["directLoadAfterOld"] == ""
|
|
assert payload["directLoadAfterNew"] == "high"
|
|
|
|
|
|
def test_blank_profile_transition_context_clears_before_explicit_model_change():
|
|
source = f"""
|
|
const uiSrc = {UI_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), 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 makeEl() {{ return {{ style: {{}}, classList: {{ toggle(){{}} }}, setAttribute(){{}}, querySelectorAll(){{ return []; }} }}; }}
|
|
const els = {{
|
|
modelSelect: {{ value: 'claude-sonnet' }},
|
|
composerReasoningWrap: makeEl(),
|
|
composerReasoningLabel: makeEl(),
|
|
composerReasoningChip: makeEl(),
|
|
composerMobileReasoningAction: makeEl()
|
|
}};
|
|
global.$ = id => els[id] || null;
|
|
global.S = {{ activeProfile: 'vops', session: null }};
|
|
global._highlightReasoningOption = () => {{}};
|
|
global._applyReasoningOptions = () => {{}};
|
|
global._modelStateForSelect = (_, model) => ({{ model, model_provider: 'anthropic' }});
|
|
var _currentReasoningEffort = 'high';
|
|
var _currentReasoningEffortsSupported = ['low', 'high'];
|
|
var _profileTransitionReasoningContext = {{ profile: 'vops', model: 'gpt-high', provider: 'openai' }};
|
|
var _lastReasoningFetchKey = '?model=gpt-high&provider=openai';
|
|
var _reasoningFetchSeq = 1;
|
|
const urls = [];
|
|
global.api = (url) => {{
|
|
urls.push(url);
|
|
return Promise.resolve({{ reasoning_effort: 'medium', supported_efforts: ['medium'] }});
|
|
}};
|
|
eval(extractFunc(uiSrc, '_normalizeReasoningEffort'));
|
|
eval(extractFunc(uiSrc, '_formatReasoningEffortLabel'));
|
|
eval(extractFunc(uiSrc, '_reasoningEffortContext'));
|
|
eval(extractFunc(uiSrc, '_reasoningEffortQuery'));
|
|
eval(extractFunc(uiSrc, '_applyReasoningChip'));
|
|
eval(extractFunc(uiSrc, 'fetchReasoningChip'));
|
|
eval(extractFunc(uiSrc, 'refreshProfileTransitionReasoningChip'));
|
|
eval(extractFunc(uiSrc, 'clearProfileTransitionReasoningContext'));
|
|
eval(extractFunc(uiSrc, 'syncReasoningChip'));
|
|
clearProfileTransitionReasoningContext();
|
|
syncReasoningChip();
|
|
const handler = bootSrc.slice(bootSrc.indexOf("$('modelSelect').onchange=async()=>"), bootSrc.indexOf("$('msg').addEventListener", bootSrc.indexOf("$('modelSelect').onchange=async()=>")));
|
|
const clearPos = handler.indexOf("clearProfileTransitionReasoningContext");
|
|
const rememberBlankPos = handler.indexOf("_rememberEmptyComposerModelOverride");
|
|
const noSessionSyncPos = handler.indexOf("syncReasoningChip()", rememberBlankPos);
|
|
console.log(JSON.stringify({{
|
|
urls,
|
|
context: _profileTransitionReasoningContext,
|
|
lastKey: _lastReasoningFetchKey,
|
|
clearPos,
|
|
noSessionSyncPos
|
|
}}));
|
|
"""
|
|
payload = json.loads(_run_node(source))
|
|
assert payload["urls"] == ["/api/reasoning?model=claude-sonnet&provider=anthropic"]
|
|
assert payload["context"] is None
|
|
assert payload["lastKey"] == "?model=claude-sonnet&provider=anthropic"
|
|
assert payload["clearPos"] >= 0
|
|
assert payload["clearPos"] < payload["noSessionSyncPos"]
|