diff --git a/CHANGELOG.md b/CHANGELOG.md index 799f0ca7..0f996984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 diff --git a/api/commands.py b/api/commands.py index 52847ab0..0a3ba5fa 100644 --- a/api/commands.py +++ b/api/commands.py @@ -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. diff --git a/api/helpers.py b/api/helpers.py index 544eb811..c9a84a81 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -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( diff --git a/api/routes.py b/api/routes.py index 520c91e1..6e09c5e6 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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 diff --git a/bootstrap.py b/bootstrap.py old mode 100644 new mode 100755 diff --git a/static/boot.js b/static/boot.js index 8fff26ed..e330add6 100644 --- a/static/boot.js +++ b/static/boot.js @@ -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') { diff --git a/static/commands.js b/static/commands.js index 756dcd55..5929b4ce 100644 --- a/static/commands.js +++ b/static/commands.js @@ -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(); diff --git a/static/messages.js b/static/messages.js index c9973fb8..724c98cb 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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; } }; diff --git a/static/panels.js b/static/panels.js index 75f77605..1f7e3e9d 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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; } diff --git a/tests/test_cli_only_slash_commands.py b/tests/test_cli_only_slash_commands.py index 99c558f5..58a2a3bd 100644 --- a/tests/test_cli_only_slash_commands.py +++ b/tests/test_cli_only_slash_commands.py @@ -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( """ diff --git a/tests/test_commands_endpoint.py b/tests/test_commands_endpoint.py index 9637a6e5..ef9b4b2d 100644 --- a/tests/test_commands_endpoint.py +++ b/tests/test_commands_endpoint.py @@ -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} diff --git a/tests/test_french_voices_tts_allowlist.py b/tests/test_french_voices_tts_allowlist.py new file mode 100644 index 00000000..2adc259b --- /dev/null +++ b/tests/test_french_voices_tts_allowlist.py @@ -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", "") diff --git a/tests/test_issue1909_csp_enforcement.py b/tests/test_issue1909_csp_enforcement.py index 2d8731ca..70976ed9 100644 --- a/tests/test_issue1909_csp_enforcement.py +++ b/tests/test_issue1909_csp_enforcement.py @@ -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 diff --git a/tests/test_issue2901_csp_connect_extra.py b/tests/test_issue2901_csp_connect_extra.py index 8056f10f..7a441ce5 100644 --- a/tests/test_issue2901_csp_connect_extra.py +++ b/tests/test_issue2901_csp_connect_extra.py @@ -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 diff --git a/tests/test_issue2977_use_command.py b/tests/test_issue2977_use_command.py index 6e32823d..e1d6830e 100644 --- a/tests/test_issue2977_use_command.py +++ b/tests/test_issue2977_use_command.py @@ -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" diff --git a/tests/test_issue4039_panel_document_title.py b/tests/test_issue4039_panel_document_title.py new file mode 100644 index 00000000..97540b65 --- /dev/null +++ b/tests/test_issue4039_panel_document_title.py @@ -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 diff --git a/tests/test_issue4041_approval_poll_busy_dip.py b/tests/test_issue4041_approval_poll_busy_dip.py new file mode 100644 index 00000000..a77ef69e --- /dev/null +++ b/tests/test_issue4041_approval_poll_busy_dip.py @@ -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 diff --git a/tests/test_issue4042_typing_lag_autoresize.py b/tests/test_issue4042_typing_lag_autoresize.py new file mode 100644 index 00000000..9ca65c13 --- /dev/null +++ b/tests/test_issue4042_typing_lag_autoresize.py @@ -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 diff --git a/tests/test_issue4053_external_skill_categories.py b/tests/test_issue4053_external_skill_categories.py new file mode 100644 index 00000000..a506dd80 --- /dev/null +++ b/tests/test_issue4053_external_skill_categories.py @@ -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 + )