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:
commit
46ca6aefb6
19 changed files with 591 additions and 26 deletions
17
CHANGELOG.md
17
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 <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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
0
bootstrap.py
Normal file → Executable 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') {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
108
tests/test_french_voices_tts_allowlist.py
Normal file
108
tests/test_french_voices_tts_allowlist.py
Normal 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", "")
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
31
tests/test_issue4039_panel_document_title.py
Normal file
31
tests/test_issue4039_panel_document_title.py
Normal 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
|
||||
48
tests/test_issue4041_approval_poll_busy_dip.py
Normal file
48
tests/test_issue4041_approval_poll_busy_dip.py
Normal 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
|
||||
27
tests/test_issue4042_typing_lag_autoresize.py
Normal file
27
tests/test_issue4042_typing_lag_autoresize.py
Normal 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
|
||||
67
tests/test_issue4053_external_skill_categories.py
Normal file
67
tests/test_issue4053_external_skill_categories.py
Normal 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
|
||||
)
|
||||
Loading…
Reference in a new issue