Merge branch 'main' into bb/gui

This commit is contained in:
emozilla
2026-05-17 02:02:28 -04:00
45 changed files with 1528 additions and 171 deletions
+8
View File
@@ -466,6 +466,14 @@ Generate some audio.
msg = build_skill_invocation_message("/nonexistent")
assert msg is None
def test_returns_none_when_skill_load_fails(self, tmp_path):
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_skill(tmp_path, "broken-skill")
scan_skill_commands()
with patch("agent.skill_commands._load_skill_payload", return_value=None):
msg = build_skill_invocation_message("/broken-skill", "do stuff")
assert msg is None
def test_uses_shared_skill_loader_for_secure_setup(self, tmp_path, monkeypatch):
monkeypatch.delenv("TENOR_API_KEY", raising=False)
calls = []
@@ -384,3 +384,148 @@ class TestIncomingDocumentHandling:
assert event.message_type == MessageType.PHOTO
assert event.media_urls == ["/tmp/cached_image.png"]
assert event.media_types == ["image/png"]
class TestAllowAnyAttachment:
"""Cover the discord.allow_any_attachment config flag.
With the flag off (default), unknown file types are dropped. With it on,
they get cached and surfaced to the agent as DOCUMENT events with
application/octet-stream MIME so gateway/run.py emits a path-pointing
context note.
"""
@pytest.mark.asyncio
async def test_unknown_type_skipped_by_default(self, adapter):
"""Default (flag off): unknown extension is dropped.
With no text + no cached media, the adapter may legitimately decline
to dispatch the event at all, so we don't assert on call_args here —
we just verify the file wasn't cached.
"""
with _mock_aiohttp_download(b"should not be cached"):
msg = make_message([
make_attachment(filename="weird.xyz", content_type="application/x-custom")
])
await adapter._handle_message(msg)
if adapter.handle_message.call_args is not None:
event = adapter.handle_message.call_args[0][0]
assert event.media_urls == []
@pytest.mark.asyncio
async def test_unknown_type_cached_when_flag_on(self, adapter):
"""Flag on: unknown extension is cached as application/octet-stream."""
adapter.config.extra["allow_any_attachment"] = True
with _mock_aiohttp_download(b"\x00\x01\x02 binary payload"):
msg = make_message([
make_attachment(filename="weird.xyz", content_type="application/x-custom")
])
await adapter._handle_message(msg)
event = adapter.handle_message.call_args[0][0]
assert len(event.media_urls) == 1
assert os.path.exists(event.media_urls[0])
# Falls back to the source content_type when we have one.
assert event.media_types == ["application/x-custom"]
assert event.message_type == MessageType.DOCUMENT
# We deliberately do NOT inline arbitrary bytes — run.py emits the
# path-pointing note based on DOCUMENT + octet-stream MIME.
assert "[Content of" not in (event.text or "")
@pytest.mark.asyncio
async def test_unknown_type_no_content_type_becomes_octet_stream(self, adapter):
"""Flag on + no content_type from discord: MIME falls back to octet-stream."""
adapter.config.extra["allow_any_attachment"] = True
with _mock_aiohttp_download(b"raw bytes"):
msg = make_message([
make_attachment(filename="mystery.bin", content_type=None)
])
await adapter._handle_message(msg)
event = adapter.handle_message.call_args[0][0]
assert event.message_type == MessageType.DOCUMENT
assert event.media_types == ["application/octet-stream"]
@pytest.mark.asyncio
async def test_max_attachment_bytes_caps_uploads(self, adapter):
"""discord.max_attachment_bytes overrides the historical 32 MiB cap."""
adapter.config.extra["allow_any_attachment"] = True
adapter.config.extra["max_attachment_bytes"] = 1024 # 1 KiB
msg = make_message([
make_attachment(
filename="too_big.xyz",
content_type="application/x-custom",
size=2048,
)
])
await adapter._handle_message(msg)
event = adapter.handle_message.call_args[0][0]
assert event.media_urls == []
@pytest.mark.asyncio
async def test_max_attachment_bytes_zero_means_unlimited(self, adapter):
"""max_attachment_bytes=0 disables the size cap entirely."""
adapter.config.extra["allow_any_attachment"] = True
adapter.config.extra["max_attachment_bytes"] = 0
# 64 MiB — would normally exceed the historical 32 MiB hardcoded cap.
with _mock_aiohttp_download(b"x" * 16):
msg = make_message([
make_attachment(
filename="huge.xyz",
content_type="application/x-custom",
size=64 * 1024 * 1024,
)
])
await adapter._handle_message(msg)
event = adapter.handle_message.call_args[0][0]
assert len(event.media_urls) == 1
@pytest.mark.asyncio
async def test_allowlisted_doc_unchanged_when_flag_on(self, adapter):
"""Flag on must not change handling of types already in SUPPORTED_DOCUMENT_TYPES.
A .txt should still get its content inlined (the historical behavior),
and the MIME should still be the canonical text/plain — not whatever
discord guessed.
"""
adapter.config.extra["allow_any_attachment"] = True
file_content = b"still a text file"
with _mock_aiohttp_download(file_content):
msg = make_message(
attachments=[make_attachment(filename="notes.txt", content_type="text/plain")],
content="check this",
)
await adapter._handle_message(msg)
event = adapter.handle_message.call_args[0][0]
assert "[Content of notes.txt]:" in event.text
assert "still a text file" in event.text
assert event.media_types == ["text/plain"]
def test_helper_reads_env_fallback(self, adapter, monkeypatch):
"""Helper falls back to DISCORD_ALLOW_ANY_ATTACHMENT env var."""
assert adapter._discord_allow_any_attachment() is False
monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "true")
assert adapter._discord_allow_any_attachment() is True
monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "no")
assert adapter._discord_allow_any_attachment() is False
def test_helper_config_overrides_env(self, adapter, monkeypatch):
"""config.yaml setting wins over env var."""
monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "true")
adapter.config.extra["allow_any_attachment"] = False
assert adapter._discord_allow_any_attachment() is False
def test_max_bytes_helper_invalid_value_falls_back(self, adapter):
"""Garbage in max_attachment_bytes config falls back to 32 MiB."""
adapter.config.extra["max_attachment_bytes"] = "not-a-number"
assert adapter._discord_max_attachment_bytes() == 32 * 1024 * 1024
+11
View File
@@ -283,6 +283,17 @@ class TestTeamsAdapterInit:
adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant"))
assert adapter._port == 5000
def test_invalid_port_from_extra_falls_back_to_default(self):
adapter = TeamsAdapter(
_make_config(client_id="id", client_secret="secret", tenant_id="tenant", port="abc")
)
assert adapter._port == 3978
def test_invalid_port_from_env_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv("TEAMS_PORT", "abc")
adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant"))
assert adapter._port == 3978
def test_platform_value(self):
adapter = TeamsAdapter(_make_config(client_id="id", client_secret="secret", tenant_id="tenant"))
assert adapter.platform.value == "teams"
+6
View File
@@ -559,3 +559,9 @@ class TestStopProfileGateway:
assert calls["kill"] == 1 # one SIGTERM
assert calls["alive_probes"] == 20 # 20 liveness polls over the 2s window
assert calls["remove"] == 0
def test_module_has_logger():
"""Verify module has a logger instance (regression guard for #27154)."""
assert hasattr(gateway, "logger")
assert gateway.logger.name == "hermes_cli.gateway"
+13
View File
@@ -173,6 +173,19 @@ def test_file_not_found_is_usage_error(fake_tool, capsys, monkeypatch):
assert "cannot read" in err.lower()
def test_file_decode_error_is_usage_error(fake_tool, capsys, monkeypatch, tmp_path):
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
bad = tmp_path / "bad-bytes.bin"
bad.write_bytes(b"\xff\xfe\x00")
args = _parse(["--to", "telegram", "--file", str(bad)])
with pytest.raises(SystemExit) as exc:
send_cmd.cmd_send(args)
assert exc.value.code == 2
err = capsys.readouterr().err
assert "cannot read" in err.lower()
def test_tool_error_returns_failure_exit(monkeypatch, capsys):
import sys as _sys
import types as _types
+28
View File
@@ -523,6 +523,34 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod):
assert env["NODE_ENV"] == "production"
def test_make_tui_argv_dev_prebuilds_hermes_ink(monkeypatch, main_mod, tmp_path):
tui_dir = tmp_path / "ui-tui"
tsx = tui_dir / "node_modules" / ".bin" / "tsx"
ink_dir = tui_dir / "packages" / "hermes-ink"
tsx.parent.mkdir(parents=True)
ink_dir.mkdir(parents=True)
tsx.write_text("#!/usr/bin/env node\n", encoding="utf-8")
monkeypatch.setattr(main_mod, "_ensure_tui_node", lambda: None)
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _tui_dir: False)
monkeypatch.delenv("HERMES_TUI_DIR", raising=False)
monkeypatch.setattr(main_mod.shutil, "which", lambda bin_name: f"/usr/bin/{bin_name}")
calls = []
def fake_run(cmd, cwd=None, **_kwargs):
calls.append((cmd, cwd))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
argv, cwd = main_mod._make_tui_argv(tui_dir, tui_dev=True)
assert argv == [str(tsx), "src/entry.tsx"]
assert cwd == tui_dir
assert calls == [(["/usr/bin/npm", "run", "build"], str(ink_dir))]
def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, capsys):
import hermes_cli.main as main_mod
@@ -182,3 +182,26 @@ class TestDeepSeekFullKwargsIntegration:
)
assert "reasoning_effort" not in kwargs
assert "extra_body" not in kwargs or "thinking" not in kwargs.get("extra_body", {})
class TestDeepSeekAuxModel:
"""DeepSeek aux model is set on the profile so users stop seeing the
bogus 'No auxiliary LLM provider configured' warning (#26924).
Pinned at the profile layer rather than the legacy
`_API_KEY_PROVIDER_AUX_MODELS_FALLBACK` dict — new providers are
expected to set `default_aux_model` on `ProviderProfile`, and the
fallback dict only exists for providers that predate the profiles
system.
"""
def test_profile_advertises_deepseek_chat(self, deepseek_profile):
assert deepseek_profile.default_aux_model == "deepseek-chat"
def test_consumer_api_returns_deepseek_chat(self):
from agent.auxiliary_client import _get_aux_model_for_provider
assert _get_aux_model_for_provider("deepseek") == "deepseek-chat"
def test_consumer_api_returns_non_empty(self):
from agent.auxiliary_client import _get_aux_model_for_provider
assert _get_aux_model_for_provider("deepseek") != ""
+48
View File
@@ -193,3 +193,51 @@ def test_background_review_summary_is_attributed_to_self_improvement_loop(monkey
assert captured_bg_callback[0].startswith("💾 Self-improvement review:"), (
captured_bg_callback[0]
)
def test_background_review_fork_skips_external_memory_plugins(monkeypatch):
"""The background review fork must NOT touch external memory plugins.
Without skip_memory=True on the fork constructor, AIAgent.__init__
rebuilds its own _memory_manager from config, scoped to the parent's
session_id. The review fork's run_conversation() then leaks the
harness prompt into the user's real memory namespace via three
ingestion sites: on_turn_start (cadence + turn message),
prefetch_all (recall query), and sync_all (harness prompt + review
output recorded as a (user, assistant) turn pair). The fix is a
single kwarg on the fork constructor — this test guards it.
"""
captured_kwargs: dict = {}
class FakeReviewAgent:
def __init__(self, **kwargs):
captured_kwargs.update(kwargs)
self._session_messages = []
def run_conversation(self, **kwargs):
pass
def shutdown_memory_provider(self):
pass
def close(self):
pass
monkeypatch.setattr(run_agent_module, "AIAgent", FakeReviewAgent)
monkeypatch.setattr(run_agent_module.threading, "Thread", ImmediateThread)
agent = _bare_agent()
AIAgent._spawn_background_review(
agent,
messages_snapshot=[{"role": "user", "content": "hello"}],
review_memory=True,
)
assert captured_kwargs.get("skip_memory") is True, (
"Background review fork must be constructed with skip_memory=True "
"so AIAgent.__init__ does not rebuild a _memory_manager wired to "
"external plugins (honcho, mem0, supermemory, ...). Without this "
"the fork leaks harness prompts into the user's real memory "
"namespace via on_turn_start / prefetch_all / sync_all."
)