Merge branch 'main' into bb/gui
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") != ""
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user