Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
+14
-20
@@ -342,39 +342,33 @@ class TestJWTTokens:
|
||||
|
||||
|
||||
class TestDiscordMentions:
|
||||
"""Discord snowflake IDs in <@ID> or <@!ID> format."""
|
||||
"""Discord mention snowflakes (<@ID> / <@!ID>) are public syntax, not
|
||||
secrets — they must pass through the redactor unchanged so multi-bot
|
||||
@-pings (DISCORD_ALLOW_BOTS=mentions) keep resolving. See issue #35611."""
|
||||
|
||||
def test_normal_mention(self):
|
||||
result = redact_sensitive_text("Hello <@222589316709220353>")
|
||||
assert "222589316709220353" not in result
|
||||
assert "<@***>" in result
|
||||
def test_normal_mention_passes_through(self):
|
||||
text = "Hello <@222589316709220353>"
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
def test_nickname_mention(self):
|
||||
result = redact_sensitive_text("Ping <@!1331549159177846844>")
|
||||
assert "1331549159177846844" not in result
|
||||
assert "<@!***>" in result
|
||||
def test_nickname_mention_passes_through(self):
|
||||
text = "Ping <@!1331549159177846844>"
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
def test_multiple_mentions(self):
|
||||
def test_multiple_mentions_pass_through(self):
|
||||
text = "<@111111111111111111> and <@222222222222222222>"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "111111111111111111" not in result
|
||||
assert "222222222222222222" not in result
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
def test_short_id_not_matched(self):
|
||||
"""IDs shorter than 17 digits are not Discord snowflakes."""
|
||||
def test_short_id_passes_through(self):
|
||||
text = "<@12345>"
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
def test_slack_mention_not_matched(self):
|
||||
"""Slack mentions use letters, not pure digits."""
|
||||
def test_slack_mention_passes_through(self):
|
||||
text = "<@U024BE7LH>"
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
def test_preserves_surrounding_text(self):
|
||||
text = "User <@222589316709220353> said hello"
|
||||
result = redact_sensitive_text(text)
|
||||
assert result.startswith("User ")
|
||||
assert result.endswith(" said hello")
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
|
||||
class TestWebUrlsNotRedacted:
|
||||
|
||||
@@ -268,6 +268,37 @@ async def test_process_message_unwraps_ephemeral_before_send():
|
||||
assert ("42", "sent-1") in adapter.deleted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_ephemeral_reply_does_not_auto_upload_bare_paths(tmp_path):
|
||||
"""Tips/system notices may mention local paths; they must remain text."""
|
||||
adapter = _delete_adapter()
|
||||
adapter._send_with_retry = AsyncMock(
|
||||
return_value=SendResult(success=True, message_id="sent-1")
|
||||
)
|
||||
adapter.send_document = AsyncMock(
|
||||
return_value=SendResult(success=True, message_id="doc-1")
|
||||
)
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text("model:\n provider: test\n", encoding="utf-8")
|
||||
reply_text = f"Tip: hermes chat --ignore-user-config skips {config_path}"
|
||||
|
||||
async def _handler(evt):
|
||||
return EphemeralReply(reply_text, ttl_seconds=0)
|
||||
|
||||
adapter.set_message_handler(_handler)
|
||||
|
||||
event = _make_event(text="/new")
|
||||
session_key = "agent:main:telegram:private:42"
|
||||
with patch("gateway.platforms.base.asyncio.sleep", AsyncMock()), patch.object(
|
||||
adapter, "_keep_typing", new=AsyncMock()
|
||||
):
|
||||
await adapter._process_message_background(event, session_key)
|
||||
|
||||
adapter._send_with_retry.assert_called_once()
|
||||
assert adapter._send_with_retry.call_args.kwargs["content"] == reply_text
|
||||
adapter.send_document.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_incapable_platform_does_not_schedule_delete():
|
||||
adapter = _no_delete_adapter()
|
||||
|
||||
@@ -728,6 +728,45 @@ class TestMediaDeliveryDefaultMode:
|
||||
|
||||
assert BasePlatformAdapter.validate_media_delivery_path(str(env_file)) is None
|
||||
|
||||
def test_denylist_blocks_hermes_config_in_active_profile(self, tmp_path, monkeypatch):
|
||||
"""The active profile config stays blocked in default mode."""
|
||||
self._patch_roots(monkeypatch)
|
||||
|
||||
fake_home = tmp_path / "home"
|
||||
hermes_dir = fake_home / ".hermes"
|
||||
hermes_dir.mkdir(parents=True)
|
||||
config_file = hermes_dir / "config.yaml"
|
||||
config_file.write_text("model:\n provider: openai\n")
|
||||
monkeypatch.setenv("HOME", str(fake_home))
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.base._HERMES_HOME",
|
||||
hermes_dir,
|
||||
)
|
||||
|
||||
assert BasePlatformAdapter.validate_media_delivery_path(str(config_file)) is None
|
||||
|
||||
def test_denylist_blocks_shared_hermes_root_config_for_profiles(self, tmp_path, monkeypatch):
|
||||
"""Profile-mode gateways must still block the shared Hermes root config."""
|
||||
self._patch_roots(monkeypatch)
|
||||
|
||||
fake_home = tmp_path / "home"
|
||||
profile_home = fake_home / ".hermes" / "profiles" / "work"
|
||||
profile_home.mkdir(parents=True)
|
||||
hermes_root = fake_home / ".hermes"
|
||||
config_file = hermes_root / "config.yaml"
|
||||
config_file.write_text("profiles:\n active: work\n")
|
||||
monkeypatch.setenv("HOME", str(fake_home))
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.base._HERMES_HOME",
|
||||
profile_home,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.base._HERMES_ROOT",
|
||||
hermes_root,
|
||||
)
|
||||
|
||||
assert BasePlatformAdapter.validate_media_delivery_path(str(config_file)) is None
|
||||
|
||||
def test_strict_mode_envvar_restores_legacy_behavior(self, tmp_path, monkeypatch):
|
||||
"""Setting HERMES_MEDIA_DELIVERY_STRICT=1 reactivates the older
|
||||
allowlist+recency logic. A stale file outside the allowlist is
|
||||
|
||||
@@ -284,6 +284,83 @@ class TestCmdUpdateBranchFallback:
|
||||
assert "API keys require manual entry" in captured.out
|
||||
|
||||
|
||||
class TestCmdUpdateMigrationPrompt:
|
||||
"""The config-migration prompt names what changed and skips the prompt
|
||||
entirely when only the config format version moved.
|
||||
|
||||
Regression guard for the contentless-prompt report (ScottFive / Tt2021):
|
||||
previously the prompt printed only counts ("1 new config option") and
|
||||
asked "configure them now?" even for pure version bumps, where saying
|
||||
yes looked like a no-op.
|
||||
"""
|
||||
|
||||
def test_version_bump_only_applies_silently_without_prompt(
|
||||
self, mock_args, capsys
|
||||
):
|
||||
"""Only the version moved → apply non-interactively, never prompt."""
|
||||
with patch("shutil.which", return_value=None), patch(
|
||||
"subprocess.run"
|
||||
) as mock_run, patch("builtins.input") as mock_input, patch(
|
||||
"hermes_cli.config.get_missing_env_vars", return_value=[]
|
||||
), patch(
|
||||
"hermes_cli.config.get_missing_config_fields", return_value=[]
|
||||
), patch(
|
||||
"hermes_cli.config.check_config_version", return_value=(5, 24)
|
||||
), patch(
|
||||
"hermes_cli.config.migrate_config",
|
||||
return_value={"env_added": [], "config_added": [], "warnings": []},
|
||||
) as mock_migrate:
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
branch="main", verify_ok=True, commit_count="1"
|
||||
)
|
||||
|
||||
cmd_update(mock_args)
|
||||
|
||||
mock_input.assert_not_called()
|
||||
mock_migrate.assert_called_once_with(interactive=False, quiet=True)
|
||||
out = capsys.readouterr().out
|
||||
assert "Updating config format (v5 → v24)" in out
|
||||
assert "no new settings to configure" in out
|
||||
# The misleading question must NOT appear for a pure version bump.
|
||||
assert "configure them now" not in out.lower()
|
||||
|
||||
def test_new_options_are_listed_by_name_before_prompt(
|
||||
self, mock_args, capsys
|
||||
):
|
||||
"""New env/config keys are printed by name so the user can decide."""
|
||||
env_items = [
|
||||
{"name": "FOO_API_KEY", "description": "Foo service API key"},
|
||||
]
|
||||
cfg_items = [
|
||||
{"key": "display.new_widget", "description": "New config option: display.new_widget"},
|
||||
]
|
||||
with patch("shutil.which", return_value=None), patch(
|
||||
"subprocess.run"
|
||||
) as mock_run, patch("builtins.input", return_value="n"), patch(
|
||||
"hermes_cli.config.get_missing_env_vars", return_value=env_items
|
||||
), patch(
|
||||
"hermes_cli.config.get_missing_config_fields", return_value=cfg_items
|
||||
), patch(
|
||||
"hermes_cli.config.check_config_version", return_value=(1, 24)
|
||||
), patch(
|
||||
"hermes_cli.config.migrate_config",
|
||||
return_value={"env_added": [], "config_added": [], "warnings": []},
|
||||
), patch("hermes_cli.main.sys") as mock_sys:
|
||||
mock_sys.stdin.isatty.return_value = True
|
||||
mock_sys.stdout.isatty.return_value = True
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
branch="main", verify_ok=True, commit_count="1"
|
||||
)
|
||||
|
||||
cmd_update(mock_args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
# Names, not just counts.
|
||||
assert "FOO_API_KEY" in out
|
||||
assert "Foo service API key" in out
|
||||
assert "display.new_widget" in out
|
||||
|
||||
|
||||
class TestCmdUpdateProfileSkillSync:
|
||||
"""cmd_update syncs bundled skills to all profiles, including the active one.
|
||||
|
||||
|
||||
@@ -67,14 +67,14 @@ class TestFetchOpenRouterModels:
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b'{"data":[{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"}},{"id":"qwen/qwen3.7-max","pricing":{"prompt":"0.000000325","completion":"0.00000195"}},{"id":"nvidia/nemotron-3-super-120b-a12b:free","pricing":{"prompt":"0","completion":"0"}}]}'
|
||||
return b'{"data":[{"id":"anthropic/claude-opus-4.8","pricing":{"prompt":"0.000015","completion":"0.000075"}},{"id":"qwen/qwen3.7-max","pricing":{"prompt":"0.000000325","completion":"0.00000195"}},{"id":"nvidia/nemotron-3-super-120b-a12b:free","pricing":{"prompt":"0","completion":"0"}}]}'
|
||||
|
||||
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
|
||||
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()):
|
||||
models = fetch_openrouter_models(force_refresh=True)
|
||||
|
||||
assert models == [
|
||||
("anthropic/claude-opus-4.6", "recommended"),
|
||||
("anthropic/claude-opus-4.8", "recommended"),
|
||||
("qwen/qwen3.7-max", ""),
|
||||
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
|
||||
]
|
||||
@@ -154,7 +154,7 @@ class TestFetchOpenRouterModels:
|
||||
# No supported_parameters field at all on either entry.
|
||||
return (
|
||||
b'{"data":['
|
||||
b'{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"}},'
|
||||
b'{"id":"anthropic/claude-opus-4.8","pricing":{"prompt":"0.000015","completion":"0.000075"}},'
|
||||
b'{"id":"qwen/qwen3.7-max","pricing":{"prompt":"0.000000325","completion":"0.00000195"}}'
|
||||
b']}'
|
||||
)
|
||||
@@ -164,7 +164,7 @@ class TestFetchOpenRouterModels:
|
||||
models = fetch_openrouter_models(force_refresh=True)
|
||||
|
||||
ids = [mid for mid, _ in models]
|
||||
assert "anthropic/claude-opus-4.6" in ids
|
||||
assert "anthropic/claude-opus-4.8" in ids
|
||||
assert "qwen/qwen3.7-max" in ids
|
||||
|
||||
|
||||
|
||||
+121
-1
@@ -4,7 +4,7 @@ import sqlite3
|
||||
import time
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state import SCHEMA_SQL, SessionDB
|
||||
|
||||
|
||||
class _NoFtsCursor(sqlite3.Cursor):
|
||||
@@ -12,6 +12,8 @@ class _NoFtsCursor(sqlite3.Cursor):
|
||||
|
||||
def execute(self, sql, parameters=()):
|
||||
probe = sql.strip()
|
||||
if "USING fts5" in probe:
|
||||
raise sqlite3.OperationalError("no such module: fts5")
|
||||
if probe in (
|
||||
"SELECT * FROM messages_fts LIMIT 0",
|
||||
"SELECT * FROM messages_fts_trigram LIMIT 0",
|
||||
@@ -30,6 +32,24 @@ class _NoFtsConnection(sqlite3.Connection):
|
||||
return super().cursor(factory or _NoFtsCursor)
|
||||
|
||||
|
||||
class _NoFtsExistingTableCursor(_NoFtsCursor):
|
||||
"""Simulate existing FTS virtual tables under a runtime without FTS5."""
|
||||
|
||||
def execute(self, sql, parameters=()):
|
||||
probe = sql.strip()
|
||||
if probe in (
|
||||
"SELECT * FROM messages_fts LIMIT 0",
|
||||
"SELECT * FROM messages_fts_trigram LIMIT 0",
|
||||
):
|
||||
raise sqlite3.OperationalError("no such module: fts5")
|
||||
return super().execute(sql, parameters)
|
||||
|
||||
|
||||
class _NoFtsExistingTableConnection(sqlite3.Connection):
|
||||
def cursor(self, factory=None):
|
||||
return super().cursor(factory or _NoFtsExistingTableCursor)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
"""Create a SessionDB with a temp database file."""
|
||||
@@ -210,6 +230,106 @@ class TestSessionLifecycle:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_existing_fts_tables_do_not_break_without_fts5(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
db_path = tmp_path / "state.db"
|
||||
seeded = SessionDB(db_path=db_path)
|
||||
try:
|
||||
seeded.create_session(session_id="s1", source="cli")
|
||||
seeded.append_message("s1", role="user", content="before runtime change")
|
||||
finally:
|
||||
seeded.close()
|
||||
|
||||
real_connect = sqlite3.connect
|
||||
|
||||
def connect_without_fts(*args, **kwargs):
|
||||
kwargs["factory"] = _NoFtsExistingTableConnection
|
||||
return real_connect(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts)
|
||||
|
||||
db = SessionDB(db_path=db_path)
|
||||
try:
|
||||
assert db._fts_enabled is False
|
||||
assert db.get_session("s1") is not None
|
||||
assert len(db.get_messages("s1")) == 1
|
||||
|
||||
# Existing FTS triggers must be disabled too; otherwise this write
|
||||
# would try to insert into an unusable FTS virtual table.
|
||||
db.append_message("s1", role="assistant", content="after runtime change")
|
||||
messages = db.get_messages("s1")
|
||||
assert len(messages) == 2
|
||||
assert messages[1]["content"] == "after runtime change"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_old_schema_without_fts5_does_not_crash(self, tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "legacy.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
conn.execute("DELETE FROM schema_version")
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", (9,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
real_connect = sqlite3.connect
|
||||
|
||||
def connect_without_fts(*args, **kwargs):
|
||||
kwargs["factory"] = _NoFtsConnection
|
||||
return real_connect(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts)
|
||||
|
||||
db = SessionDB(db_path=db_path)
|
||||
try:
|
||||
assert db._fts_enabled is False
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message("s1", role="user", content="legacy no fts")
|
||||
assert db.get_messages("s1")[0]["content"] == "legacy no fts"
|
||||
assert db.search_messages("legacy") == []
|
||||
|
||||
# Leave the FTS migration version in place so a future FTS-capable
|
||||
# runtime can still rebuild and backfill the indexes.
|
||||
row = db._conn.execute("SELECT version FROM schema_version").fetchone()
|
||||
assert row["version"] == 9
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_fts_runtime_restores_triggers_after_no_fts_open(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
db_path = tmp_path / "state.db"
|
||||
seeded = SessionDB(db_path=db_path)
|
||||
try:
|
||||
seeded.create_session(session_id="s1", source="cli")
|
||||
seeded.append_message("s1", role="user", content="first searchable")
|
||||
finally:
|
||||
seeded.close()
|
||||
|
||||
real_connect = sqlite3.connect
|
||||
|
||||
def connect_without_fts(*args, **kwargs):
|
||||
kwargs["factory"] = _NoFtsExistingTableConnection
|
||||
return real_connect(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts)
|
||||
no_fts = SessionDB(db_path=db_path)
|
||||
try:
|
||||
no_fts.append_message("s1", role="assistant", content="not indexed yet")
|
||||
finally:
|
||||
no_fts.close()
|
||||
|
||||
monkeypatch.setattr("hermes_state.sqlite3.connect", real_connect)
|
||||
restored = SessionDB(db_path=db_path)
|
||||
try:
|
||||
assert restored._fts_enabled is True
|
||||
restored.append_message("s1", role="assistant", content="indexed again")
|
||||
assert len(restored.search_messages("not indexed yet")) == 1
|
||||
assert len(restored.search_messages("indexed")) == 2
|
||||
finally:
|
||||
restored.close()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Message storage
|
||||
|
||||
Reference in New Issue
Block a user