Merge pull request #4074 from nesquena/stage-mj

Release MJ (v0.51.371): low-risk batch — approval polling, composer/titlebar polish, slash-command parity, skill categories, French TTS
This commit is contained in:
nesquena-hermes 2026-06-12 13:44:07 -07:00 committed by GitHub
commit 46ca6aefb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 591 additions and 26 deletions

View file

@ -3,6 +3,23 @@
## [Unreleased]
## [v0.51.371] — 2026-06-12 — Release MJ (low-risk batch: approval polling, composer/titlebar polish, slash-command parity, skill categories, French TTS)
### Fixed
- **Tool-approval prompts no longer disappear during brief idle gaps (#4041).** The HTTP approval poll used to tear itself down — force-hiding the approval card past its visibility guard before the server had confirmed the approval was gone — on any transient busy=false dip. It now keeps polling across busy dips and stops only on a true session switch or a confirmed-idle state with no pending approvals. (#4041)
- **Typing in the composer is smoother on long sessions (#4042).** The composer auto-resize ran a synchronous layout read on every keystroke; it's now coalesced into a single animation-frame update. (#4042)
- **The desktop window title stays correct when switching to non-chat panels (#4039).** Switching to Settings/Tasks/etc. now stamps `document.title`, and returning to chat restores the chat title. (#4039)
- **The Tauri desktop app's local IPC is no longer blocked by the Content-Security-Policy (#4040).** `http://ipc.localhost` is now part of the default `connect-src` allowlist. (#4040)
- **`bootstrap.py` is tracked with its executable bit (#3914)** so direct POSIX execution keeps working after a fresh clone.
- **`/reload-skills` now works from the WebUI (#4043).** It's routed through the same narrow WebUI agent-command allowlist as `/reload-mcp`, re-scans the installed skills and reports what changed. (#4043)
- **`/use <skill>` now injects the actual skill content for the next turn (#4044).** Instead of only telling the agent to consult a skill, the selected skill's body is fetched and carried through to the next message as forced context. (#4044)
- **Flat external skill directories are now categorized in the Skills tab (#4053).** Skills directly under an external skills root use that root's directory name as their category, while flat skills under the local skills root stay uncategorized. (#4053)
### Added
- **French Edge TTS voices (#4058).** Seven `fr-CA` / `fr-FR` neural voices are now in the Edge TTS allowlist, so French-speaking users who enable Edge TTS no longer get a 400 on every utterance.
## [v0.51.370] — 2026-06-12 — Release MI (model picker shows real providers when /api/models rebuild times out)
### Fixed

View file

@ -24,10 +24,12 @@ _NEVER_EXPOSE: frozenset[str] = frozenset({
# Narrow agent-side execution allowlist for /api/commands/exec.
_AGENT_COMMAND_ALIASES = {
'reload_mcp': 'reload-mcp',
'reload_skills': 'reload-skills',
'codex_runtime': 'codex-runtime',
}
_ALLOWED_AGENT_COMMANDS = frozenset({'reload-mcp', 'codex-runtime'})
_ALLOWED_AGENT_COMMANDS = frozenset({'reload-mcp', 'reload-skills', 'codex-runtime'})
_RELOAD_MCP_LOCK = threading.Lock()
_RELOAD_SKILLS_LOCK = threading.Lock()
_CODEX_RUNTIME_LOCK = threading.Lock()
@ -120,6 +122,8 @@ def execute_agent_command(command: str) -> str:
if canonical == 'reload-mcp':
return _run_reload_mcp_command()
if canonical == 'reload-skills':
return _run_reload_skills_command()
if canonical == 'codex-runtime':
return _run_codex_runtime_command(arg_string)
@ -204,6 +208,54 @@ def _run_reload_mcp_command() -> str:
return "\n".join(lines)
def _run_reload_skills_command() -> str:
"""Re-scan the installed skills directory and summarize the diff."""
with _RELOAD_SKILLS_LOCK:
try:
from agent.skill_commands import reload_skills
except Exception as exc:
logger.warning("Failed to import skills runtime for /reload-skills", exc_info=True)
raise RuntimeError("Skills runtime unavailable") from exc
try:
result = reload_skills() or {}
except Exception as exc:
logger.warning("Failed to reload skills", exc_info=True)
raise RuntimeError("Failed to reload skills") from exc
added = result.get("added", [])
removed = result.get("removed", [])
unchanged = result.get("unchanged", [])
total = int(result.get("total", 0) or 0)
def _names(items: Any) -> list[str]:
out: list[str] = []
for item in items or []:
if isinstance(item, dict):
name = str(item.get("name", "")).strip()
else:
name = str(item).strip()
if name:
out.append(name)
return out
added_names = _names(added)
removed_names = _names(removed)
lines = [
"Reloaded skills from disk.",
f"Added: {len(added_names)}",
f"Removed: {len(removed_names)}",
f"Unchanged: {len(list(unchanged or []))}",
f"Total skills: {total}",
]
if added_names:
lines.append(f"Added skills: {', '.join(sorted(added_names))}")
if removed_names:
lines.append(f"Removed skills: {', '.join(sorted(removed_names))}")
return "\n".join(lines)
def execute_plugin_command(command: str) -> str:
"""Execute a plugin-registered slash command and return printable output.

View file

@ -53,7 +53,7 @@ def safe_resolve(root: Path, requested: str) -> Path:
_CSP_CONNECT_BASE = (
"'self' http://127.0.0.1:* http://localhost:* "
"'self' http://127.0.0.1:* http://localhost:* http://ipc.localhost "
"ws://127.0.0.1:* ws://localhost:*"
)
_CSP_EXTRA_CONNECT_RE = _re.compile(

View file

@ -226,7 +226,24 @@ def _skill_path_within(base_dir: Path, candidate: Path) -> bool:
return False
def _skill_category_from_path(skill_md: Path, skills_dirs: list[Path]) -> str | None:
def _skill_category_from_path(
skill_md: Path,
skills_dirs: list[Path],
local_skills_dir: Path | None = None,
) -> str | None:
"""Return the UI category for a discovered skill path.
Flat skills directly under the active *local* skills root stay uncategorized,
while flat skills under an *external* root use that root's directory name as
their category. ``local_skills_dir`` identifies the local root explicitly; if
omitted it falls back to ``skills_dirs[0]`` for backward compatibility, but
callers should pass it directly because the local root can be filtered out of
``skills_dirs`` (e.g. when it does not exist yet on a host with only external
skills configured), which would otherwise misclassify the first external root
as local.
"""
if local_skills_dir is None:
local_skills_dir = skills_dirs[0] if skills_dirs else None
for skills_dir in skills_dirs:
try:
rel_path = skill_md.relative_to(skills_dir)
@ -235,6 +252,8 @@ def _skill_category_from_path(skill_md: Path, skills_dirs: list[Path]) -> str |
parts = rel_path.parts
if len(parts) >= 3:
return parts[0]
if len(parts) >= 2 and local_skills_dir is not None and skills_dir != local_skills_dir:
return skills_dir.name
return None
return None
@ -391,7 +410,9 @@ def _skills_list_from_dir(skills_dir: Path, category: str | None = None) -> dict
{
"name": name,
"description": description,
"category": _skill_category_from_path(skill_md, search_dirs),
"category": _skill_category_from_path(
skill_md, search_dirs, local_skills_dir=skills_dir
),
"disabled": name in disabled,
}
)
@ -10318,7 +10339,10 @@ def _handle_tts(handler, parsed):
allowed = {
"zh-CN-XiaoxiaoNeural", "zh-CN-XiaoyiNeural", "zh-CN-YunxiNeural",
"zh-CN-YunjianNeural", "zh-CN-YunyangNeural",
"en-US-AriaNeural", "en-US-GuyNeural"
"en-US-AriaNeural", "en-US-GuyNeural",
"fr-CA-AntoineNeural", "fr-CA-JeanNeural",
"fr-CA-SylvieNeural", "fr-CA-ThierryNeural",
"fr-FR-DeniseNeural", "fr-FR-EloiseNeural", "fr-FR-HenriNeural",
}
if voice not in allowed:
from api.helpers import bad as _bad

0
bootstrap.py Normal file → Executable file
View file

View file

@ -1288,8 +1288,8 @@ $('modelSelect').onchange=async()=>{
}
};
$('msg').addEventListener('input',()=>{
autoResize();
updateSendBtn();
scheduleComposerAutoResize();
// Persist composer draft to server (debounced in _saveComposerDraft).
const sid = S && S.session && S.session.session_id;
if (sid && typeof _saveComposerDraft === 'function') {

View file

@ -949,8 +949,11 @@ async function cmdUse(args){
}
return;
}
const directive = `[USER OVERRIDE] You MUST consult skill '${match.name}' via skill_view before responding to the next message.`;
resolve(directive);
const detail = await api(`/api/skills/content?name=${encodeURIComponent(match.name)}`);
const skillContent = detail&&typeof detail.content==='string' ? detail.content.trim() : '';
if(!skillContent) throw new Error(`Skill \`${match.name}\` has no readable content.`);
const directive = `[USER OVERRIDE] You MUST follow the skill '${match.name}' content provided below before responding to the next message.`;
resolve({name:match.name,directive,content:skillContent});
if(isCurrentSession()){
S.messages.push({role:'assistant', content:`Next turn: skill \`${match.name}\` will be forced.`});
renderMessages();

View file

@ -792,7 +792,7 @@ const _sessionTitleProvisionalBySid = new Map();
// their canonical command is registered on the backend (for example
// /reload-mcp). Keep this intentionally narrow and include underscore variants
// observed by users so typing either form still routes through executeAgentCommand.
const _AGENT_COMMANDS_RUN_ON_WEBUI = new Set(['reload-mcp', 'reload_mcp', 'codex-runtime', 'codex_runtime']);
const _AGENT_COMMANDS_RUN_ON_WEBUI = new Set(['reload-mcp', 'reload_mcp', 'reload-skills', 'reload_skills', 'codex-runtime', 'codex_runtime']);
function _clearStaleBusyStateBeforeSend({compressionRunning=false}={}){
if(!S||!S.busy||compressionRunning) return false;
@ -1063,10 +1063,22 @@ async function send(){
if(_forcedSkillDirectivePending){
const _pending=_forcedSkillDirectivePending;
if(!_pending.sessionId||_pending.sessionId===activeSid){
const _directive = await _pending.promise;
const _directivePayload = await _pending.promise;
if(_forcedSkillDirectivePending===_pending)_forcedSkillDirectivePending = null;
if(typeof _directive==='string'&&_directive){
msgText=`${_directive}\n\n${msgText||''}`.trim();
if(_directivePayload){
const _directive = typeof _directivePayload==='string'
? _directivePayload
: String(_directivePayload.directive||'').trim();
const _forcedSkillName = typeof _directivePayload==='string'
? ''
: String(_directivePayload.name||'').trim();
const _forcedSkillContent = typeof _directivePayload==='string'
? ''
: String(_directivePayload.content||'').trim();
const _forcedSkillBlock = _forcedSkillName&&_forcedSkillContent
? `[FORCED SKILL CONTEXT: ${_forcedSkillName}]\n${_forcedSkillContent}\n[/FORCED SKILL CONTEXT]`
: '';
msgText=`${_directive}${_forcedSkillBlock?`\n\n${_forcedSkillBlock}`:''}\n\n${msgText||''}`.trim();
}
}
}
@ -3868,7 +3880,25 @@ function transcript(){
return lines.join('\n');
}
function autoResize(){const el=$('msg');el.style.height='auto';el.style.height=Math.min(el.scrollHeight,200)+'px';updateSendBtn();}
let _composerAutoResizeRaf=0;
function autoResize(){
if(_composerAutoResizeRaf && typeof cancelAnimationFrame==='function'){
cancelAnimationFrame(_composerAutoResizeRaf);
_composerAutoResizeRaf=0;
}
const el=$('msg');
el.style.height='auto';
el.style.height=Math.min(el.scrollHeight,200)+'px';
updateSendBtn();
}
function scheduleComposerAutoResize(){
if(typeof requestAnimationFrame!=='function'){autoResize();return;}
if(_composerAutoResizeRaf) return;
_composerAutoResizeRaf=requestAnimationFrame(()=>{
_composerAutoResizeRaf=0;
autoResize();
});
}
// ── YOLO mode state ──
@ -3994,6 +4024,10 @@ function _hideApprovalCardIfOwner(sid, force=false) {
if (!sid || _approvalSessionId === sid) hideApprovalCard(force);
}
function _approvalPollingSessionMissingOrMismatched(sid) {
return !sid || !S.session || S.session.session_id !== sid;
}
function _renderPendingApprovalForActiveSession() {
const sid = _promptActiveSessionId();
if (!sid) return;
@ -4160,7 +4194,7 @@ function _startApprovalFallbackPoll(sid) {
// shows its card instantly (the removed SSE 'initial' event used to do this);
// then poll on the 1500ms cadence. (#3913 SHOULD-FIX)
const _tick = async () => {
if (!S.busy || !S.session || S.session.session_id !== sid) {
if (_approvalPollingSessionMissingOrMismatched(sid)) {
stopApprovalPolling(); _hideApprovalCardIfOwner(sid, true); return;
}
if (_approvalFallbackPollInFlight) return;
@ -4168,7 +4202,13 @@ function _startApprovalFallbackPoll(sid) {
try {
const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false});
if (data.pending) { showApprovalForSession(sid, data.pending, data.pending_count||1); }
else { _clearApprovalPendingForSession(sid); _hideApprovalCardIfOwner(sid); }
else if (!_approvalPollingSessionMissingOrMismatched(sid)) {
_clearApprovalPendingForSession(sid);
_hideApprovalCardIfOwner(sid);
if (!S.busy) {
stopApprovalPollingForSession(sid);
}
}
} catch(e) { /* ignore poll errors */ }
finally { _approvalFallbackPollInFlight = false; }
};

View file

@ -72,6 +72,10 @@ function syncAppTitlebar() {
if (_renamingAppTitlebar) return;
titleEl.textContent = mainText;
if (panel !== 'chat') {
const bot = typeof assistantDisplayName === 'function' ? assistantDisplayName() : '';
document.title = bot ? mainText + ' \u2014 ' + bot : mainText;
}
if (subEl) {
if (subText) {
subEl.textContent = subText;
@ -272,7 +276,8 @@ async function switchPanel(name, opts = {}) {
if (overlay) overlay.classList.add('visible');
}
_resyncChatSidebarAfterPanelSwitch();
syncAppTitlebar();
if (nextPanel === 'chat' && typeof syncTopbar === 'function') syncTopbar();
else syncAppTitlebar();
return true;
}

View file

@ -120,6 +120,13 @@ def _run_commands_js(script_body: str) -> dict:
aliases: ['codex_runtime'],
cli_only: false,
gateway_only: false
}},
{{
name: 'reload-skills',
description: 'Re-scan installed skills',
aliases: ['reload_skills'],
cli_only: false,
gateway_only: false
}}
]
}};
@ -252,14 +259,36 @@ def test_send_intercepts_reload_mcp_agent_command_before_agent_round_trip():
assert "executeAgentCommand(text,_agentCmd||{name:_agentCmdName})" in intercept
def test_reload_mcp_and_codex_runtime_webui_intercept_aliases_are_defined_in_js_whitelist():
def test_reload_mcp_reload_skills_and_codex_runtime_webui_intercept_aliases_are_defined_in_js_whitelist():
assert "'reload-mcp'" in MESSAGES_JS
assert "'reload_mcp'" in MESSAGES_JS
assert "'reload-skills'" in MESSAGES_JS
assert "'reload_skills'" in MESSAGES_JS
assert "'codex-runtime'" in MESSAGES_JS
assert "'codex_runtime'" in MESSAGES_JS
assert "if(_agentCmd&&_AGENT_COMMANDS_RUN_ON_WEBUI.has(_agentCmdName))" not in MESSAGES_JS
def test_reload_skills_agent_command_metadata_resolves_alias():
result = _run_commands_js(
"""
const byName = await getAgentCommandMetadata('reload-skills');
const byAlias = await getAgentCommandMetadata('reload_skills');
return {
by_name: byName && byName.name,
by_alias: byAlias && byAlias.name,
cli_only: byAlias && byAlias.cli_only === true
};
"""
)
assert result == {
"by_name": "reload-skills",
"by_alias": "reload-skills",
"cli_only": False,
}
def test_codex_runtime_agent_command_metadata_resolves_alias():
result = _run_commands_js(
"""

View file

@ -60,6 +60,17 @@ def _install_fake_codex_runtime_switch(monkeypatch):
return calls
def _install_fake_skill_commands(monkeypatch, reload_skills):
import sys
agent_pkg = sys.modules.get("agent") or ModuleType("agent")
agent_pkg.__path__ = []
skill_commands = ModuleType("agent.skill_commands")
skill_commands.reload_skills = reload_skills
monkeypatch.setitem(sys.modules, "agent", agent_pkg)
monkeypatch.setitem(sys.modules, "agent.skill_commands", skill_commands)
return skill_commands
def _get(path):
"""GET helper -- returns parsed JSON or raises HTTPError."""
with urllib.request.urlopen(TEST_BASE + path, timeout=10) as r:
@ -153,6 +164,24 @@ def test_commands_exec_runs_reload_mcp_alias():
assert isinstance(body['output'], str)
@requires_agent_modules
def test_commands_exec_runs_reload_skills_command():
"""`/reload-skills` executes through the same narrow shared executor path."""
status, body = _post('/api/commands/exec', {'command': '/reload-skills'})
assert status == 200
assert 'output' in body
assert isinstance(body['output'], str)
@requires_agent_modules
def test_commands_exec_runs_reload_skills_alias():
"""Telegram-style underscore alias resolves to reload-skills in the executor."""
status, body = _post('/api/commands/exec', {'command': '/reload_skills'})
assert status == 200
assert 'output' in body
assert isinstance(body['output'], str)
def test_codex_runtime_command_uses_shared_switch_and_persists(monkeypatch, tmp_path):
"""`/codex-runtime` executes through the same shared switch as CLI/gateway."""
calls = _install_fake_codex_runtime_switch(monkeypatch)
@ -243,6 +272,75 @@ def test_reload_mcp_error_is_generic(monkeypatch):
assert calls == ["shutdown"]
def test_reload_skills_command_formats_helper_diff(monkeypatch):
"""`/reload-skills` should summarize the shared helper diff in printable text."""
def reload_skills():
return {
"added": [{"name": "incident-review", "description": "desc"}],
"removed": [{"name": "legacy-skill", "description": "old"}],
"unchanged": ["skills", "use"],
"total": 3,
"commands": 3,
}
_install_fake_skill_commands(monkeypatch, reload_skills)
from api.commands import execute_agent_command
output = execute_agent_command('/reload-skills')
assert output == "\n".join([
"Reloaded skills from disk.",
"Added: 1",
"Removed: 1",
"Unchanged: 2",
"Total skills: 3",
"Added skills: incident-review",
"Removed skills: legacy-skill",
])
def test_reload_skills_command_accepts_underscore_alias(monkeypatch):
"""Telegram/WebUI underscore spelling routes to the canonical skills reload."""
calls = []
def reload_skills():
calls.append("reload_skills")
return {
"added": [],
"removed": [],
"unchanged": [],
"total": 0,
"commands": 0,
}
_install_fake_skill_commands(monkeypatch, reload_skills)
from api.commands import execute_agent_command
output = execute_agent_command('/reload_skills')
assert calls == ["reload_skills"]
assert "Added: 0" in output
assert "Removed: 0" in output
def test_reload_skills_error_is_generic(monkeypatch):
"""`/reload-skills` failures must return a generic message, not internals."""
def reload_skills():
raise RuntimeError("secret_path=C:/Users/Rod/.hermes/skills/private")
_install_fake_skill_commands(monkeypatch, reload_skills)
from api.commands import execute_agent_command
with pytest.raises(RuntimeError) as exc:
execute_agent_command('/reload-skills')
assert str(exc.value) == "Failed to reload skills"
assert 'secret_path=' not in str(exc.value)
def test_concurrent_reload_mcp_calls_are_serialized(monkeypatch):
"""Concurrent `/reload-mcp` calls cannot run shutdown/discover interleaved."""
state = {"active": 0, "max_active": 0}

View file

@ -0,0 +1,108 @@
"""Coverage for French voices in the Edge TTS allowlist.
Mirrors the in-process / mocked-edge_tts pattern from
test_issue2931_edge_tts_endpoint.py to assert each French voice in the
allowlist reaches synthesis (HTTP 200) and that an unlisted French locale
(fr-BE) is still rejected at the allowlist (HTTP 400).
"""
import io
import json
import sys
from types import SimpleNamespace
import pytest
import api.routes as routes
class _FakeHandler:
def __init__(self, body: bytes, command: str = "POST", headers=None, client="1.2.3.4"):
self.command = command
self.rfile = io.BytesIO(body)
self.wfile = io.BytesIO()
self.headers = headers or {}
self.headers.setdefault("Content-Length", str(len(body)))
self.client_address = (client, 12345)
self.status = None
self.sent_headers = {}
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.sent_headers[key] = value
def end_headers(self):
pass
def payload(self):
try:
return json.loads(self.wfile.getvalue().decode("utf-8"))
except Exception:
return None
def _post(body_dict, **kw):
body = json.dumps(body_dict).encode()
return _FakeHandler(body, **kw)
def _reset_limiter():
if hasattr(routes._handle_tts, "_tts_limiter"):
del routes._handle_tts._tts_limiter
@pytest.fixture(autouse=True)
def _fresh_tts_limiter(monkeypatch):
import api.auth as _auth
monkeypatch.setattr(_auth, "is_auth_enabled", lambda: False)
monkeypatch.setattr(routes, "is_auth_enabled", lambda: False, raising=False)
monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False)
_reset_limiter()
yield
_reset_limiter()
FRENCH_VOICES = [
"fr-CA-AntoineNeural",
"fr-CA-JeanNeural",
"fr-CA-SylvieNeural",
"fr-CA-ThierryNeural",
"fr-FR-DeniseNeural",
"fr-FR-EloiseNeural",
"fr-FR-HenriNeural",
]
@pytest.mark.parametrize("voice", FRENCH_VOICES)
def test_french_voice_in_allowlist_reaches_synthesis(monkeypatch, voice):
captured = {}
class FakeCommunicate:
def __init__(self, text, voice, **kwargs):
captured["text"] = text
captured["voice"] = voice
def stream_sync(self):
yield {"type": "audio", "data": b"abc"}
monkeypatch.setitem(sys.modules, "edge_tts", SimpleNamespace(Communicate=FakeCommunicate))
# Unique client per voice so the 2s per-client rate limiter does not throttle
# successive parametrized runs in this module.
client = f"10.99.0.{FRENCH_VOICES.index(voice) + 1}"
h = _post({"text": "Bonjour", "voice": voice}, client=client)
routes._handle_tts(h, None)
assert h.status == 200, h.payload()
assert captured["voice"] == voice
def test_unlisted_french_locale_still_rejected():
# fr-BE is a real Edge voice locale but is intentionally not in the
# allowlist; it must still be rejected at the voice check before any
# synthesis is attempted.
h = _post({"text": "Bonjour", "voice": "fr-BE-CharlineNeural"}, client="10.99.1.1")
routes._handle_tts(h, None)
assert h.status == 400
assert "invalid voice" in (h.payload() or {}).get("error", "")

View file

@ -50,7 +50,7 @@ def test_security_helper_sends_enforcing_csp_with_hardening_directives(monkeypat
assert "object-src 'none'" in policy
assert "frame-ancestors 'none'" in policy
assert "media-src 'self' data: blob:" in policy
assert "connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* https://cdn.jsdelivr.net" in policy
assert "connect-src 'self' http://127.0.0.1:* http://localhost:* http://ipc.localhost ws://127.0.0.1:* ws://localhost:* https://cdn.jsdelivr.net" in policy
def test_enforcing_csp_honors_valid_extra_connect_origins(monkeypatch):
@ -64,7 +64,7 @@ def test_enforcing_csp_honors_valid_extra_connect_origins(monkeypatch):
policy = headers["Content-Security-Policy"]
assert (
"connect-src 'self' http://127.0.0.1:* http://localhost:* "
"ws://127.0.0.1:* ws://localhost:* https://cdn.jsdelivr.net "
"http://ipc.localhost ws://127.0.0.1:* ws://localhost:* https://cdn.jsdelivr.net "
"https://metrics.example.com wss://events.example.com:443; "
) in policy

View file

@ -9,11 +9,13 @@ def test_csp_connect_src_default_header_unchanged(monkeypatch):
monkeypatch.delenv("HERMES_WEBUI_CSP_CONNECT_EXTRA", raising=False)
policy = Handler.csp_report_only_policy()
assert (
expected = (
"connect-src 'self' http://127.0.0.1:* http://localhost:* "
"ws://127.0.0.1:* ws://localhost:* https://cdn.jsdelivr.net; "
) in policy
"http://ipc.localhost ws://127.0.0.1:* ws://localhost:* "
"https://cdn.jsdelivr.net; "
)
assert expected in policy
def test_csp_connect_src_includes_valid_extra_origins(monkeypatch):
@ -28,7 +30,8 @@ def test_csp_connect_src_includes_valid_extra_origins(monkeypatch):
assert (
"connect-src 'self' http://127.0.0.1:* http://localhost:* "
"ws://127.0.0.1:* ws://localhost:* https://cdn.jsdelivr.net "
"http://ipc.localhost ws://127.0.0.1:* ws://localhost:* "
"https://cdn.jsdelivr.net "
"https://metrics.example.com wss://events.example.com:443; "
) in policy

View file

@ -34,6 +34,8 @@ def test_forced_skill_directive_set_in_cmdUse():
src = read("static/commands.js")
assert "pending.promise = new Promise" in src, "cmdUse must create a pending Promise"
assert "_forcedSkillDirectivePending = pending;" in src, "cmdUse must publish the pending directive before awaiting"
assert "resolve({name:match.name,directive,content:skillContent});" in src, \
"cmdUse must resolve the pending payload with skill name, directive, and fetched content"
def test_use_entry_has_noEcho():
@ -59,7 +61,7 @@ def test_directive_consumed_at_injection_site():
finally_part = src.split("finally")[1] if "finally" in src else ""
assert "_forcedSkillDirectivePending = null;" not in finally_part, \
"_forcedSkillDirectivePending must NOT be cleared in the finally block"
assert "const _directive = await _pending.promise;" in src, \
assert "const _directivePayload = await _pending.promise;" in src, \
"consume site must await the pending promise"
assert "_forcedSkillDirectivePending = null;" in src, \
"_forcedSkillDirectivePending must be cleared somewhere in messages.js"
@ -75,7 +77,16 @@ def test_directive_injection_before_empty_guard():
def test_directive_text_uses_match_name():
src = read("static/commands.js")
assert "match.name" in src, "directive must use match.name (canonical casing), not raw user input"
assert "[USER OVERRIDE] You MUST consult skill '" in src, "directive text must match the specified format"
assert "[USER OVERRIDE] You MUST follow the skill '" in src, "directive text must match the specified format"
assert "content provided below" in src, "directive must reference the injected skill content"
def test_use_fetches_canonical_skill_content():
src = read("static/commands.js")
assert "api(`/api/skills/content?name=${encodeURIComponent(match.name)}`)" in src, \
"cmdUse must fetch the canonical skill content after resolving the canonical skill name"
assert "typeof detail.content==='string' ? detail.content.trim() : ''" in src, \
"cmdUse must reject missing or non-string skill content"
def test_pending_promise_set_synchronously():
@ -114,3 +125,5 @@ def test_directive_only_consumed_by_matching_session():
"send() must only consume /use directives issued for the active session"
assert "if(_forcedSkillDirectivePending===_pending)_forcedSkillDirectivePending = null;" in src, \
"send() must not clear a newer pending directive created while awaiting"
assert "[FORCED SKILL CONTEXT: ${_forcedSkillName}]" in src, \
"send() must prepend deterministic forced-skill content before the user message"

View file

@ -0,0 +1,31 @@
"""Regression coverage for panel-driven document.title ownership (#4039)."""
from __future__ import annotations
def _src(name: str) -> str:
with open(f"static/{name}", encoding="utf-8") as f:
return f.read()
def test_sync_app_titlebar_stamps_non_chat_document_title():
src = _src("panels.js")
assert "if (panel !== 'chat') {" in src
assert "const bot = typeof assistantDisplayName === 'function' ? assistantDisplayName() : '';" in src
assert "document.title = bot ? mainText + ' \\u2014 ' + bot : mainText;" in src
def test_switch_panel_restores_chat_title_via_sync_topbar():
src = _src("panels.js")
assert "if (nextPanel === 'chat' && typeof syncTopbar === 'function') syncTopbar();" in src
assert "else syncAppTitlebar();" in src
def test_chat_title_format_stays_owned_by_sync_topbar():
panels_src = _src("panels.js")
ui_src = _src("ui.js")
assert "document.title=sessionTitle+' \\u2014 '+assistantDisplayName();" in ui_src
assert "document.title=sessionTitle+' \\u2014 '+assistantDisplayName();" not in panels_src

View file

@ -0,0 +1,48 @@
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
def _body_from_brace(src: str, brace: int, label: str) -> str:
assert brace >= 0, f"body opening brace not found for: {label}"
depth = 1
i = brace + 1
while i < len(src) and depth:
ch = src[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
i += 1
assert depth == 0, f"body did not close for: {label}"
return src[brace + 1 : i - 1]
def _function_body(name: str) -> str:
marker = f"function {name}("
start = MESSAGES_JS.find(marker)
assert start >= 0, f"function not found: {name}"
signature_end = MESSAGES_JS.find(")", start)
assert signature_end >= 0, f"function signature not found: {name}"
brace = MESSAGES_JS.find("{", signature_end)
return _body_from_brace(MESSAGES_JS, brace, name)
def test_busy_dips_no_longer_force_stop_approval_polling():
body = _function_body("_startApprovalFallbackPoll")
assert "_approvalPollingSessionMissingOrMismatched(sid)" in body
assert "!S.busy || !S.session || S.session.session_id !== sid" not in body
def test_empty_pending_clears_card_and_only_stops_after_confirmed_idle():
fallback_body = _function_body("_startApprovalFallbackPoll")
assert "else if (!_approvalPollingSessionMissingOrMismatched(sid)) {" in fallback_body
assert "_clearApprovalPendingForSession(sid);" in fallback_body
assert "_hideApprovalCardIfOwner(sid);" in fallback_body
assert "if (!S.busy) {" in fallback_body
assert "stopApprovalPollingForSession(sid);" in fallback_body
assert "else if (!_approvalPollingSessionMissingOrMismatched(sid) && !S.busy)" not in fallback_body
assert "stopApprovalPolling(); _hideApprovalCardIfOwner(sid, true); return;" in fallback_body

View file

@ -0,0 +1,27 @@
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
def test_composer_autoresize_is_single_flight_per_frame():
assert "let _composerAutoResizeRaf=0;" in MESSAGES_JS
assert "function scheduleComposerAutoResize()" in MESSAGES_JS
assert "if(typeof requestAnimationFrame!=='function'){autoResize();return;}" in MESSAGES_JS
assert "if(_composerAutoResizeRaf) return;" in MESSAGES_JS
assert "_composerAutoResizeRaf=requestAnimationFrame(()=>{" in MESSAGES_JS
assert "cancelAnimationFrame(_composerAutoResizeRaf);" in MESSAGES_JS
assert "_composerAutoResizeRaf=0;" in MESSAGES_JS
assert "autoResize();" in MESSAGES_JS
def test_composer_input_listener_uses_scheduler_instead_of_direct_reflow():
start = BOOT_JS.index("$('msg').addEventListener('input',()=>{")
end = BOOT_JS.index("// Persist composer draft to server", start)
listener = BOOT_JS[start:end]
assert "scheduleComposerAutoResize();" in listener
assert "autoResize();" not in listener
assert "updateSendBtn();" in listener

View file

@ -0,0 +1,67 @@
import pathlib
from tests.conftest import requires_agent_modules
pytestmark = requires_agent_modules
def _write_skill(root: pathlib.Path, *parts: str) -> pathlib.Path:
skill_dir = root.joinpath(*parts)
skill_dir.mkdir(parents=True, exist_ok=True)
skill_md = skill_dir / "SKILL.md"
skill_md.write_text(
"---\nname: test-skill\ndescription: test description\n---\n",
encoding="utf-8",
)
return skill_md
def test_external_skill_categories_keep_local_flat_and_label_external_roots(tmp_path):
from api import routes
local_root = tmp_path / "skills"
external_flat_root = tmp_path / "partner-skills"
external_nested_root = tmp_path / "external-catalog"
local_skill = _write_skill(local_root, "local-flat-skill")
flat_external_skill = _write_skill(external_flat_root, "external-flat-skill")
nested_external_skill = _write_skill(
external_nested_root,
"ops",
"external-nested-skill",
)
search_dirs = [local_root, external_flat_root, external_nested_root]
assert routes._skill_category_from_path(local_skill, search_dirs) is None
assert (
routes._skill_category_from_path(flat_external_skill, search_dirs)
== external_flat_root.name
)
assert routes._skill_category_from_path(nested_external_skill, search_dirs) == "ops"
def test_external_skill_category_when_local_dir_absent_from_search_dirs(tmp_path):
"""When the local skills dir does not exist it is filtered out of the search
list (``_active_skill_search_dirs`` keeps only existing dirs). Passing the
local dir explicitly must keep its flat skills uncategorized AND still let a
flat external root use its directory name — instead of misidentifying the
first surviving (external) root as local."""
from api import routes
local_root = tmp_path / "skills" # intentionally never created
external_flat_root = tmp_path / "partner-skills"
flat_external_skill = _write_skill(external_flat_root, "external-flat-skill")
# _active_skill_search_dirs would drop the missing local_root; simulate that.
search_dirs = [external_flat_root]
# Without local_skills_dir, the old position-based logic would treat the
# external root as local and return None. With it passed explicitly, the
# external root is correctly labeled.
assert (
routes._skill_category_from_path(
flat_external_skill, search_dirs, local_skills_dir=local_root
)
== external_flat_root.name
)