Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
@@ -203,6 +203,30 @@ class TestListAuthenticatedProvidersBedrock:
|
||||
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
|
||||
assert bedrock is None, "bedrock should NOT appear when AWS credentials are absent"
|
||||
|
||||
def test_non_bedrock_picker_does_not_probe_full_aws_chain(self, monkeypatch):
|
||||
"""Non-Bedrock provider discovery must not touch boto3's full credential chain."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
monkeypatch.delenv("AWS_PROFILE", raising=False)
|
||||
monkeypatch.delenv("AWS_ACCESS_KEY_ID", raising=False)
|
||||
monkeypatch.delenv("AWS_SECRET_ACCESS_KEY", raising=False)
|
||||
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||
monkeypatch.delenv("AWS_WEB_IDENTITY_TOKEN_FILE", raising=False)
|
||||
monkeypatch.delenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", raising=False)
|
||||
monkeypatch.delenv("AWS_CONTAINER_CREDENTIALS_FULL_URI", raising=False)
|
||||
|
||||
calls = {"has_aws_credentials": 0}
|
||||
|
||||
def _has_aws_credentials():
|
||||
calls["has_aws_credentials"] += 1
|
||||
return False
|
||||
|
||||
with patch("agent.bedrock_adapter.has_aws_credentials", side_effect=_has_aws_credentials):
|
||||
providers = list_authenticated_providers(current_provider="openrouter", max_models=0)
|
||||
|
||||
assert calls["has_aws_credentials"] == 0
|
||||
assert all(p["slug"] != "bedrock" for p in providers)
|
||||
|
||||
def test_bedrock_falls_back_to_curated_when_discovery_fails(self, monkeypatch):
|
||||
"""When discover_bedrock_models() raises, fall back to curated list without crashing."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
@@ -822,6 +822,103 @@ class TestClampTelegramNames:
|
||||
assert result[0] == ("foo", "d1")
|
||||
|
||||
|
||||
class TestClampCommandNamesTriples:
|
||||
"""Tests for _clamp_command_names with 3-tuples (name, desc, cmd_key).
|
||||
|
||||
Skill entries pass through _clamp_command_names as 3-tuples so the
|
||||
original cmd_key survives name truncation. Before the fix in PR #18951,
|
||||
the code stripped cmd_key into a side-dict keyed by the *original*
|
||||
(name, desc) pair — after truncation the lookup key no longer matched,
|
||||
silently losing the cmd_key.
|
||||
"""
|
||||
|
||||
def test_short_triple_preserved(self):
|
||||
entries = [("skill", "A skill", "/skill")]
|
||||
result = _clamp_command_names(entries, set())
|
||||
assert result == [("skill", "A skill", "/skill")]
|
||||
|
||||
def test_long_name_preserves_cmd_key(self):
|
||||
long = "a" * 50
|
||||
cmd_key = f"/{long}"
|
||||
result = _clamp_command_names([(long, "desc", cmd_key)], set())
|
||||
assert len(result) == 1
|
||||
name, desc, key = result[0]
|
||||
assert len(name) == _CMD_NAME_LIMIT
|
||||
assert key == cmd_key, "cmd_key must survive name clamping"
|
||||
|
||||
def test_collision_preserves_cmd_key(self):
|
||||
prefix = "x" * _CMD_NAME_LIMIT
|
||||
long = "x" * 50
|
||||
result = _clamp_command_names(
|
||||
[(long, "desc", "/long-skill")], reserved={prefix},
|
||||
)
|
||||
assert len(result) == 1
|
||||
name, _desc, key = result[0]
|
||||
assert name == "x" * (_CMD_NAME_LIMIT - 1) + "0"
|
||||
assert key == "/long-skill"
|
||||
|
||||
def test_multiple_long_names_preserve_respective_keys(self):
|
||||
base = "y" * 40
|
||||
entries = [
|
||||
(base + "_alpha", "d1", "/alpha-skill"),
|
||||
(base + "_beta", "d2", "/beta-skill"),
|
||||
]
|
||||
result = _clamp_command_names(entries, set())
|
||||
assert len(result) == 2
|
||||
assert result[0][2] == "/alpha-skill"
|
||||
assert result[1][2] == "/beta-skill"
|
||||
|
||||
def test_backward_compat_with_pairs(self):
|
||||
"""Legacy 2-tuple callers (Telegram) must still work."""
|
||||
entries = [("help", "Show help"), ("status", "Show status")]
|
||||
result = _clamp_command_names(entries, set())
|
||||
assert result == entries
|
||||
|
||||
|
||||
class TestDiscordSkillCmdKeyDispatch:
|
||||
"""Integration: discord_skill_commands preserves cmd_key for long names.
|
||||
|
||||
This tests the full pipeline: skill_commands → _collect_gateway_skill_entries
|
||||
→ _clamp_command_names → returned triples, verifying that skills with names
|
||||
exceeding Discord's 32-char limit still have their original cmd_key for
|
||||
dispatch.
|
||||
"""
|
||||
|
||||
def test_long_skill_name_retains_cmd_key(self, tmp_path, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
|
||||
long_name = "this-is-a-very-long-skill-name-that-exceeds-limit"
|
||||
cmd_key = f"/{long_name}"
|
||||
fake_skills_dir = tmp_path / "skills"
|
||||
fake_skills_dir.mkdir(exist_ok=True)
|
||||
# Use resolved path — macOS /var → /private/var symlink
|
||||
# causes SKILLS_DIR.resolve() to differ from tmp_path.
|
||||
resolved_dir = str(fake_skills_dir.resolve())
|
||||
|
||||
fake_cmds = {
|
||||
cmd_key: {
|
||||
"name": long_name,
|
||||
"description": "A skill with a long name",
|
||||
"skill_md_path": f"{resolved_dir}/{long_name}/SKILL.md",
|
||||
"skill_dir": f"{resolved_dir}/{long_name}",
|
||||
},
|
||||
}
|
||||
|
||||
with patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), \
|
||||
patch("tools.skills_tool.SKILLS_DIR", fake_skills_dir), \
|
||||
patch("agent.skill_utils.get_external_skills_dirs", return_value=[]):
|
||||
entries, hidden = discord_skill_commands(
|
||||
max_slots=100, reserved_names=set(),
|
||||
)
|
||||
|
||||
assert len(entries) == 1
|
||||
name, desc, key = entries[0]
|
||||
assert len(name) <= _CMD_NAME_LIMIT, "Name should be clamped to 32 chars"
|
||||
assert key == cmd_key, (
|
||||
f"cmd_key must be the original /{long_name}, got {key!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestTelegramMenuCommands:
|
||||
"""Integration: telegram_menu_commands enforces the 32-char limit."""
|
||||
|
||||
@@ -899,6 +996,73 @@ class TestTelegramMenuCommands:
|
||||
assert "my_enabled_skill" in menu_names
|
||||
assert "my_disabled_skill" not in menu_names
|
||||
|
||||
def test_external_dir_skills_included_in_telegram_menu(self, tmp_path, monkeypatch):
|
||||
"""External skills (``skills.external_dirs``) must appear in the Telegram menu.
|
||||
|
||||
Regression test for #8110 — external skills were visible to the
|
||||
agent and CLI but silently excluded from gateway slash menus
|
||||
because ``_collect_gateway_skill_entries`` only accepted skills
|
||||
whose path started with ``SKILLS_DIR``.
|
||||
|
||||
Also verifies the trailing-slash boundary: a directory that
|
||||
simply shares a prefix with a configured ``external_dirs`` entry
|
||||
(``/tmp/my-skills-extra`` vs ``/tmp/my-skills``) must NOT be
|
||||
admitted.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
local_dir = tmp_path / "skills"
|
||||
local_dir.mkdir()
|
||||
external_dir = tmp_path / "my-skills"
|
||||
external_dir.mkdir()
|
||||
lookalike_dir = tmp_path / "my-skills-extra"
|
||||
lookalike_dir.mkdir()
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
f"skills:\n external_dirs:\n - {external_dir}\n"
|
||||
)
|
||||
|
||||
fake_cmds = {
|
||||
"/local-one": {
|
||||
"name": "local-one",
|
||||
"description": "Local",
|
||||
"skill_md_path": f"{local_dir}/local-one/SKILL.md",
|
||||
"skill_dir": f"{local_dir}/local-one",
|
||||
},
|
||||
"/morning-briefing": {
|
||||
"name": "morning-briefing",
|
||||
"description": "External skill",
|
||||
"skill_md_path": f"{external_dir}/morning-briefing/SKILL.md",
|
||||
"skill_dir": f"{external_dir}/morning-briefing",
|
||||
},
|
||||
"/lookalike-skill": {
|
||||
"name": "lookalike-skill",
|
||||
"description": "Lives in a sibling dir that shares a prefix",
|
||||
"skill_md_path": f"{lookalike_dir}/lookalike-skill/SKILL.md",
|
||||
"skill_dir": f"{lookalike_dir}/lookalike-skill",
|
||||
},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
|
||||
patch("tools.skills_tool.SKILLS_DIR", local_dir),
|
||||
patch(
|
||||
"agent.skill_utils.get_external_skills_dirs",
|
||||
return_value=[external_dir],
|
||||
),
|
||||
):
|
||||
menu, _ = telegram_menu_commands(max_commands=100)
|
||||
|
||||
menu_names = {n for n, _ in menu}
|
||||
assert "local_one" in menu_names, "local skill must appear"
|
||||
assert "morning_briefing" in menu_names, (
|
||||
"external skill from skills.external_dirs must appear (fixes #8110)"
|
||||
)
|
||||
assert "lookalike_skill" not in menu_names, (
|
||||
"prefix-match sibling directories must not be admitted"
|
||||
)
|
||||
|
||||
def test_special_chars_in_skill_names_sanitized(self, tmp_path, monkeypatch):
|
||||
"""Skills with +, /, or other special chars produce valid Telegram names."""
|
||||
from unittest.mock import patch
|
||||
@@ -1353,6 +1517,119 @@ class TestDiscordSkillCommandsByCategory:
|
||||
assert "vllm" in names
|
||||
assert len(uncategorized) == 0
|
||||
|
||||
def test_no_legacy_25x25_cap(self, tmp_path, monkeypatch):
|
||||
"""The old nested-layout caps (25 groups × 25 skills/group) are gone.
|
||||
|
||||
The live caller flattens categories into a single autocomplete list,
|
||||
which Discord fetches dynamically — the per-command 8KB payload
|
||||
concern from the old nested layout (#11321, #10259) no longer applies.
|
||||
Guards against accidentally re-introducing the caps, which would
|
||||
silently drop skills in the 26th+ alphabetical category (the exact
|
||||
failure mode users were hitting with 29 category dirs on real
|
||||
installs).
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
fake_skills_dir = str(tmp_path / "skills")
|
||||
|
||||
# Build 30 categories (> old _MAX_GROUPS=25) each with 30 skills
|
||||
# (> old _MAX_PER_GROUP=25).
|
||||
fake_cmds = {}
|
||||
for c in range(30):
|
||||
cat = f"cat{c:02d}" # cat00, cat01, ..., cat29 — 30 categories
|
||||
for s in range(30):
|
||||
name = f"skill-{c:02d}-{s:02d}"
|
||||
skill_subdir = tmp_path / "skills" / cat / name
|
||||
skill_subdir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_subdir / "SKILL.md").write_text("---\nname: x\n---\n")
|
||||
fake_cmds[f"/{name}"] = {
|
||||
"name": name,
|
||||
"description": f"Category {cat} skill {s}",
|
||||
"skill_md_path": f"{fake_skills_dir}/{cat}/{name}/SKILL.md",
|
||||
}
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
with (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
|
||||
):
|
||||
categories, uncategorized, hidden = discord_skill_commands_by_category(
|
||||
reserved_names=set(),
|
||||
)
|
||||
|
||||
# Every category should be present — no 25-group cap
|
||||
assert len(categories) == 30, (
|
||||
f"expected all 30 categories, got {len(categories)} "
|
||||
f"(cap from old nested layout must be removed)"
|
||||
)
|
||||
# Every skill in every category must be present — no 25-per-group cap
|
||||
for cat_name, entries in categories.items():
|
||||
assert len(entries) == 30, (
|
||||
f"category {cat_name}: expected 30 skills, got {len(entries)} "
|
||||
f"(cap from old nested layout must be removed)"
|
||||
)
|
||||
# Nothing should be reported hidden for the cap reason (the only
|
||||
# legitimate hidden reason now is name clamp collisions, which
|
||||
# don't happen here since all names are unique).
|
||||
assert hidden == 0
|
||||
|
||||
def test_external_dirs_skills_included(self, tmp_path, monkeypatch):
|
||||
"""Skills in ``skills.external_dirs`` must appear in /skill autocomplete.
|
||||
|
||||
#18741 fixed this for the flat ``discord_skill_commands`` collector
|
||||
but left ``discord_skill_commands_by_category`` (the live caller for
|
||||
Discord's ``/skill`` command) still filtering by
|
||||
``SKILLS_DIR`` prefix only. Regression guard that both collectors
|
||||
now accept external-dir skills.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
local_skills_dir = tmp_path / "local-skills"
|
||||
external_dir = tmp_path / "external-skills"
|
||||
|
||||
(local_skills_dir / "creative" / "local-skill").mkdir(parents=True)
|
||||
(local_skills_dir / "creative" / "local-skill" / "SKILL.md").write_text("")
|
||||
|
||||
(external_dir / "mlops" / "external-skill").mkdir(parents=True)
|
||||
(external_dir / "mlops" / "external-skill" / "SKILL.md").write_text("")
|
||||
|
||||
fake_cmds = {
|
||||
"/local-skill": {
|
||||
"name": "local-skill",
|
||||
"description": "Local",
|
||||
"skill_md_path": str(local_skills_dir / "creative" / "local-skill" / "SKILL.md"),
|
||||
},
|
||||
"/external-skill": {
|
||||
"name": "external-skill",
|
||||
"description": "External",
|
||||
"skill_md_path": str(external_dir / "mlops" / "external-skill" / "SKILL.md"),
|
||||
},
|
||||
}
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
with (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
|
||||
patch("tools.skills_tool.SKILLS_DIR", local_skills_dir),
|
||||
patch(
|
||||
"agent.skill_utils.get_external_skills_dirs",
|
||||
return_value=[external_dir],
|
||||
),
|
||||
):
|
||||
categories, uncategorized, hidden = discord_skill_commands_by_category(
|
||||
reserved_names=set(),
|
||||
)
|
||||
|
||||
# Local skill → grouped under "creative"
|
||||
assert "creative" in categories
|
||||
assert any(n == "local-skill" for n, _d, _k in categories["creative"])
|
||||
# External skill → grouped under its own top-level dir "mlops"
|
||||
assert "mlops" in categories, (
|
||||
"external-dir skills must be included — the old SKILLS_DIR-only "
|
||||
"prefix check was broken for by_category (completes #18741)"
|
||||
)
|
||||
assert any(n == "external-skill" for n, _d, _k in categories["mlops"])
|
||||
assert uncategorized == []
|
||||
assert hidden == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin slash command integration
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Tests for Discord /skill 32-char clamp collision warnings.
|
||||
|
||||
Discord's per-command name limit is 32 chars, so
|
||||
``discord_skill_commands_by_category`` clamps skill slugs to that width
|
||||
before deduping. When two skills share the same 32-char prefix, only
|
||||
the first (alphabetical) wins; the second is dropped. Previously the
|
||||
drop was silent — the ``hidden`` count incremented but nothing named
|
||||
which skills collided, so authors had no way to discover the drop
|
||||
short of noticing that their skill was missing from the autocomplete.
|
||||
|
||||
This module pins the upgraded behavior: a WARNING log with both full
|
||||
cmd_keys + the clamped name, so whoever named the skills sees the
|
||||
collision and can rename one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_clamp_collision_emits_warning_naming_both_skills(
|
||||
tmp_path: Path, caplog
|
||||
) -> None:
|
||||
"""Two skills with identical first 32 chars — warning names both."""
|
||||
from hermes_cli.commands import discord_skill_commands_by_category
|
||||
|
||||
# Craft cmd_keys that share the first 32 chars.
|
||||
# 40-char prefix 'skill-collision-prefix-identical-first-32'
|
||||
# -> clamped to 'skill-collision-prefix-identical'
|
||||
prefix = "skill-collision-prefix-identical" # exactly 32 chars
|
||||
name_a = prefix + "-alpha" # /skill-collision-prefix-identical-alpha
|
||||
name_b = prefix + "-bravo" # /skill-collision-prefix-identical-bravo
|
||||
assert name_a[:32] == name_b[:32] == prefix
|
||||
|
||||
skills_dir = tmp_path / "skills"
|
||||
for nm in (name_a, name_b):
|
||||
d = skills_dir / "creative" / nm
|
||||
d.mkdir(parents=True)
|
||||
(d / "SKILL.md").write_text("---\nname: x\n---\n")
|
||||
|
||||
fake_cmds = {
|
||||
f"/{name_a}": {
|
||||
"name": name_a,
|
||||
"description": "Alpha",
|
||||
"skill_md_path": str(skills_dir / "creative" / name_a / "SKILL.md"),
|
||||
},
|
||||
f"/{name_b}": {
|
||||
"name": name_b,
|
||||
"description": "Bravo",
|
||||
"skill_md_path": str(skills_dir / "creative" / name_b / "SKILL.md"),
|
||||
},
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="hermes_cli.commands"), (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds)
|
||||
), patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
||||
categories, uncategorized, hidden = discord_skill_commands_by_category(
|
||||
reserved_names=set(),
|
||||
)
|
||||
|
||||
# One skill made it through, one was dropped (hidden counted).
|
||||
assert hidden == 1
|
||||
kept_names = [n for n, _d, _k in categories.get("creative", [])]
|
||||
assert len(kept_names) == 1
|
||||
# Alphabetical iteration means the -alpha variant wins the slot.
|
||||
assert kept_names[0] == prefix # clamped
|
||||
|
||||
# Exactly one warning, naming BOTH full cmd_keys and the clamped name.
|
||||
warnings = [
|
||||
r for r in caplog.records
|
||||
if r.levelno == logging.WARNING and "clamp" in r.getMessage()
|
||||
]
|
||||
assert len(warnings) == 1, (
|
||||
f"expected exactly one clamp-collision warning, got {len(warnings)}: "
|
||||
f"{[r.getMessage() for r in warnings]}"
|
||||
)
|
||||
msg = warnings[0].getMessage()
|
||||
assert f"/{name_a}" in msg, f"winner not named in warning: {msg!r}"
|
||||
assert f"/{name_b}" in msg, f"loser not named in warning: {msg!r}"
|
||||
assert prefix in msg, f"clamped name not in warning: {msg!r}"
|
||||
|
||||
|
||||
def test_clamp_collision_with_reserved_name_emits_distinct_warning(
|
||||
tmp_path: Path, caplog
|
||||
) -> None:
|
||||
"""A skill clashing with a reserved gateway command gets its own phrasing.
|
||||
|
||||
The reserved-vs-skill case is operationally different — the fix is
|
||||
still "rename the skill," but there's no second skill to also
|
||||
rename. The warning should say so explicitly.
|
||||
"""
|
||||
from hermes_cli.commands import discord_skill_commands_by_category
|
||||
|
||||
# Reserved name 'help' is 4 chars — make a skill whose slug
|
||||
# clamps to 'help' (so, exactly 'help').
|
||||
reserved = "help"
|
||||
skills_dir = tmp_path / "skills"
|
||||
d = skills_dir / "creative" / reserved
|
||||
d.mkdir(parents=True)
|
||||
(d / "SKILL.md").write_text("---\nname: x\n---\n")
|
||||
|
||||
fake_cmds = {
|
||||
f"/{reserved}": {
|
||||
"name": reserved,
|
||||
"description": "desc",
|
||||
"skill_md_path": str(d / "SKILL.md"),
|
||||
},
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="hermes_cli.commands"), (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds)
|
||||
), patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
||||
categories, uncategorized, hidden = discord_skill_commands_by_category(
|
||||
reserved_names={"help"},
|
||||
)
|
||||
|
||||
# Skill dropped in favor of the reserved command.
|
||||
assert hidden == 1
|
||||
assert categories == {}
|
||||
assert uncategorized == []
|
||||
|
||||
warnings = [
|
||||
r for r in caplog.records
|
||||
if r.levelno == logging.WARNING and "reserved" in r.getMessage()
|
||||
]
|
||||
assert len(warnings) == 1, (
|
||||
f"expected one reserved-name collision warning, got "
|
||||
f"{[r.getMessage() for r in warnings]}"
|
||||
)
|
||||
msg = warnings[0].getMessage()
|
||||
assert f"/{reserved}" in msg
|
||||
assert "reserved" in msg.lower()
|
||||
|
||||
|
||||
def test_no_collision_no_warning(tmp_path: Path, caplog) -> None:
|
||||
"""Sanity: two distinct-prefix skills produce zero warnings."""
|
||||
from hermes_cli.commands import discord_skill_commands_by_category
|
||||
|
||||
skills_dir = tmp_path / "skills"
|
||||
for nm in ("alpha", "bravo"):
|
||||
d = skills_dir / "creative" / nm
|
||||
d.mkdir(parents=True)
|
||||
(d / "SKILL.md").write_text("---\nname: x\n---\n")
|
||||
|
||||
fake_cmds = {
|
||||
"/alpha": {
|
||||
"name": "alpha", "description": "",
|
||||
"skill_md_path": str(skills_dir / "creative" / "alpha" / "SKILL.md"),
|
||||
},
|
||||
"/bravo": {
|
||||
"name": "bravo", "description": "",
|
||||
"skill_md_path": str(skills_dir / "creative" / "bravo" / "SKILL.md"),
|
||||
},
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="hermes_cli.commands"), (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds)
|
||||
), patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
||||
categories, uncategorized, hidden = discord_skill_commands_by_category(
|
||||
reserved_names=set(),
|
||||
)
|
||||
|
||||
assert hidden == 0
|
||||
assert {n for n, _d, _k in categories["creative"]} == {"alpha", "bravo"}
|
||||
clamp_warnings = [
|
||||
r for r in caplog.records
|
||||
if r.levelno == logging.WARNING
|
||||
and ("clamp" in r.getMessage() or "reserved" in r.getMessage())
|
||||
]
|
||||
assert clamp_warnings == []
|
||||
|
||||
|
||||
def test_long_skill_name_preserves_cmd_key_through_by_category(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Skills with names > 32 chars must keep their original cmd_key.
|
||||
|
||||
``discord_skill_commands_by_category`` clamps the display name to 32
|
||||
chars but the third tuple element (cmd_key) must stay as the original
|
||||
``/full-skill-name`` so that ``_skill_handler`` dispatches via
|
||||
``_run_simple_slash`` with the full command, not the truncated one.
|
||||
|
||||
This is the actual runtime path used by the Discord adapter via
|
||||
``_refresh_skill_catalog_state``.
|
||||
"""
|
||||
from hermes_cli.commands import discord_skill_commands_by_category
|
||||
|
||||
skills_dir = tmp_path / "skills"
|
||||
skills_dir.mkdir()
|
||||
resolved = str(skills_dir.resolve())
|
||||
|
||||
long_name = "generate-ascii-art-from-text-description-detailed"
|
||||
cmd_key = f"/{long_name}"
|
||||
fake_cmds = {
|
||||
cmd_key: {
|
||||
"name": long_name,
|
||||
"description": "Generate ASCII art from a text description",
|
||||
"skill_md_path": f"{resolved}/creative/{long_name}/SKILL.md",
|
||||
"skill_dir": f"{resolved}/creative/{long_name}",
|
||||
},
|
||||
"/short-skill": {
|
||||
"name": "short-skill",
|
||||
"description": "A short skill",
|
||||
"skill_md_path": f"{resolved}/creative/short-skill/SKILL.md",
|
||||
"skill_dir": f"{resolved}/creative/short-skill",
|
||||
},
|
||||
}
|
||||
|
||||
with patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), \
|
||||
patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
||||
categories, uncategorized, hidden = discord_skill_commands_by_category(
|
||||
reserved_names=set(),
|
||||
)
|
||||
|
||||
# Flatten (same as _refresh_skill_catalog_state does)
|
||||
entries = list(uncategorized)
|
||||
for cat_skills in categories.values():
|
||||
entries.extend(cat_skills)
|
||||
|
||||
# Build lookup (same as _refresh_skill_catalog_state does)
|
||||
skill_lookup = {n: (d, k) for n, d, k in entries}
|
||||
|
||||
# Find the long skill
|
||||
long_entry = [e for e in entries if e[2] == cmd_key]
|
||||
assert len(long_entry) == 1, f"Long skill should appear once, got: {long_entry}"
|
||||
|
||||
display_name, desc, key = long_entry[0]
|
||||
assert len(display_name) <= 32, (
|
||||
f"Display name should be clamped to 32 chars, got {len(display_name)}"
|
||||
)
|
||||
assert key == cmd_key, (
|
||||
f"cmd_key must be the original /{long_name}, got {key!r}"
|
||||
)
|
||||
|
||||
# Verify lookup works: clamped display name -> original cmd_key
|
||||
assert display_name in skill_lookup
|
||||
_desc, looked_up_key = skill_lookup[display_name]
|
||||
assert looked_up_key == cmd_key, (
|
||||
f"Lookup must map clamped name to original cmd_key, got {looked_up_key!r}"
|
||||
)
|
||||
|
||||
# Short skill should also be present and correct
|
||||
short_entry = [e for e in entries if e[2] == "/short-skill"]
|
||||
assert len(short_entry) == 1
|
||||
assert short_entry[0][0] == "short-skill"
|
||||
@@ -51,6 +51,57 @@ class TestProviderEnvDetection:
|
||||
assert not _has_provider_env_config(content)
|
||||
|
||||
|
||||
class TestDoctorEnvFileEncoding:
|
||||
"""Regression for #18637 (bug 3): `hermes doctor` crashed on Windows
|
||||
Chinese locale (GBK) because `.env` was read with Path.read_text() which
|
||||
defaults to the system locale encoding, not UTF-8."""
|
||||
|
||||
def test_doctor_reads_env_as_utf8_even_when_locale_is_not_utf8(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
import pathlib
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
# Write a UTF-8 .env containing an em dash (U+2014 = e2 80 94). The
|
||||
# 0x94 byte is exactly the one the issue reporter hit: it's invalid
|
||||
# as a GBK trailing byte in this position, so locale-default reads
|
||||
# raise UnicodeDecodeError on Chinese Windows.
|
||||
env_path = hermes_home / ".env"
|
||||
env_path.write_text(
|
||||
"OPENAI_API_KEY=sk-test # em-dash here — should not crash\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)
|
||||
|
||||
orig_read_text = pathlib.Path.read_text
|
||||
|
||||
def gbk_like_read_text(self, encoding=None, errors=None, **kwargs):
|
||||
# Simulate a GBK locale: refuse to decode this specific UTF-8
|
||||
# .env unless the caller pins encoding="utf-8".
|
||||
if self == env_path and encoding != "utf-8":
|
||||
raise UnicodeDecodeError(
|
||||
"gbk", b"\x94", 0, 1, "illegal multibyte sequence"
|
||||
)
|
||||
return orig_read_text(self, encoding=encoding, errors=errors, **kwargs)
|
||||
|
||||
monkeypatch.setattr(pathlib.Path, "read_text", gbk_like_read_text)
|
||||
|
||||
# Short-circuit the expensive tool-availability probe — we only
|
||||
# need doctor to reach the .env read without crashing.
|
||||
fake_model_tools = types.SimpleNamespace(
|
||||
check_tool_availability=lambda *a, **kw: (_ for _ in ()).throw(SystemExit(0)),
|
||||
TOOLSET_REQUIREMENTS={},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
# Run doctor. If the .env read still uses locale encoding, this
|
||||
# raises UnicodeDecodeError and the test fails.
|
||||
with pytest.raises(SystemExit):
|
||||
doctor_mod.run_doctor(Namespace(fix=False))
|
||||
|
||||
|
||||
class TestDoctorToolAvailabilityOverrides:
|
||||
def test_marks_honcho_available_when_configured(self, monkeypatch):
|
||||
monkeypatch.setattr(doctor, "_honcho_is_configured_for_doctor", lambda: True)
|
||||
|
||||
@@ -4,11 +4,16 @@ from hermes_cli.setup import setup_agent_settings
|
||||
|
||||
|
||||
def test_setup_agent_settings_uses_displayed_max_iterations_value(tmp_path, monkeypatch, capsys):
|
||||
"""The helper text should match the value shown in the prompt."""
|
||||
"""The helper text should match the value shown in the prompt.
|
||||
|
||||
After PR#18413 max_turns is read exclusively from config.yaml — the
|
||||
.env `HERMES_MAX_ITERATIONS` fallback was removed because it was
|
||||
shadowing the user's current config (see the 60-vs-500 incident).
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
config = {
|
||||
"agent": {"max_turns": 90},
|
||||
"agent": {"max_turns": 60},
|
||||
"display": {"tool_progress": "all"},
|
||||
"compression": {"threshold": 0.50},
|
||||
"session_reset": {"mode": "both", "idle_minutes": 1440, "at_hour": 4},
|
||||
@@ -16,10 +21,10 @@ def test_setup_agent_settings_uses_displayed_max_iterations_value(tmp_path, monk
|
||||
|
||||
prompt_answers = iter(["60", "all", "0.5"])
|
||||
|
||||
monkeypatch.setattr("hermes_cli.setup.get_env_value", lambda key: "60" if key == "HERMES_MAX_ITERATIONS" else "")
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: next(prompt_answers))
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda *args, **kwargs: 4)
|
||||
monkeypatch.setattr("hermes_cli.setup.save_env_value", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr("hermes_cli.setup.remove_env_value", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr("hermes_cli.setup.save_config", lambda *args, **kwargs: None)
|
||||
|
||||
setup_agent_settings(config)
|
||||
@@ -27,3 +32,47 @@ def test_setup_agent_settings_uses_displayed_max_iterations_value(tmp_path, monk
|
||||
out = capsys.readouterr().out
|
||||
assert "Press Enter to keep 60." in out
|
||||
assert "Default is 90" not in out
|
||||
|
||||
|
||||
def test_setup_agent_settings_prefers_config_over_stale_env(tmp_path, monkeypatch, capsys):
|
||||
"""Config.yaml wins even when a stale .env value disagrees.
|
||||
|
||||
Regression guard for the bug where `.env HERMES_MAX_ITERATIONS=60`
|
||||
from an old `hermes setup` run shadowed `agent.max_turns: 500` in
|
||||
config.yaml. The wizard must now display the config value.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
config = {
|
||||
"agent": {"max_turns": 500}, # user bumped this in config.yaml
|
||||
"display": {"tool_progress": "all"},
|
||||
"compression": {"threshold": 0.50},
|
||||
"session_reset": {"mode": "both", "idle_minutes": 1440, "at_hour": 4},
|
||||
}
|
||||
|
||||
prompt_answers = iter(["500", "all", "0.5"])
|
||||
|
||||
# Simulate stale .env value — the wizard must ignore this.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.setup.get_env_value",
|
||||
lambda key: "60" if key == "HERMES_MAX_ITERATIONS" else "",
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: next(prompt_answers))
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda *args, **kwargs: 4)
|
||||
monkeypatch.setattr("hermes_cli.setup.save_env_value", lambda *args, **kwargs: None)
|
||||
|
||||
removed_keys: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.setup.remove_env_value",
|
||||
lambda key: (removed_keys.append(key), True)[1],
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.setup.save_config", lambda *args, **kwargs: None)
|
||||
|
||||
setup_agent_settings(config)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
# Config value wins
|
||||
assert "Press Enter to keep 500." in out
|
||||
assert "Press Enter to keep 60." not in out
|
||||
# And the stale .env entry gets cleaned up
|
||||
assert "HERMES_MAX_ITERATIONS" in removed_keys
|
||||
|
||||
@@ -8,6 +8,7 @@ from hermes_cli.tools_config import (
|
||||
_configure_provider,
|
||||
_get_platform_tools,
|
||||
_platform_toolset_summary,
|
||||
_reconfigure_tool,
|
||||
_save_platform_tools,
|
||||
_toolset_has_keys,
|
||||
CONFIGURABLE_TOOLSETS,
|
||||
@@ -468,6 +469,33 @@ def test_local_browser_provider_is_saved_explicitly(monkeypatch):
|
||||
assert config["browser"]["cloud_provider"] == "local"
|
||||
|
||||
|
||||
def test_reconfigure_lists_enabled_web_without_existing_provider_config(monkeypatch):
|
||||
config = {"platform_toolsets": {"cli": ["web"]}}
|
||||
seen = {}
|
||||
configured = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._toolset_has_keys",
|
||||
lambda ts_key, config=None: False,
|
||||
)
|
||||
|
||||
def fake_prompt_choice(question, choices, default=0):
|
||||
seen["choices"] = choices
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr("hermes_cli.tools_config._prompt_choice", fake_prompt_choice)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._configure_tool_category_for_reconfig",
|
||||
lambda ts_key, cat, config: configured.append(ts_key),
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.tools_config.save_config", lambda config: None)
|
||||
|
||||
_reconfigure_tool(config)
|
||||
|
||||
assert any("Web Search" in choice for choice in seen["choices"])
|
||||
assert configured == ["web"]
|
||||
|
||||
|
||||
def test_first_install_nous_auto_configures_managed_defaults(monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.tools_config.managed_nous_tools_enabled", lambda: True)
|
||||
monkeypatch.setattr("hermes_cli.nous_subscription.managed_nous_tools_enabled", lambda: True)
|
||||
|
||||
Reference in New Issue
Block a user