Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
40
tests/agent/test_vision_resolved_args.py
Normal file
40
tests/agent/test_vision_resolved_args.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Test that call_llm vision path passes resolved provider args, not raw ones."""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
def test_vision_call_uses_resolved_provider_args():
|
||||
"""Resolved provider/model/key/url from config must reach resolve_vision_provider_client."""
|
||||
from agent.auxiliary_client import call_llm
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.chat.completions.create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content="description"))],
|
||||
usage=MagicMock(prompt_tokens=10, completion_tokens=5),
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("my-resolved-provider", "my-resolved-model", "http://resolved", "resolved-key", "chat_completions"),
|
||||
),
|
||||
patch(
|
||||
"agent.auxiliary_client.resolve_vision_provider_client",
|
||||
return_value=("my-resolved-provider", fake_client, "my-resolved-model"),
|
||||
) as mock_vision,
|
||||
):
|
||||
call_llm(
|
||||
"vision",
|
||||
provider="raw-provider",
|
||||
model="raw-model",
|
||||
base_url="http://raw",
|
||||
api_key="raw-key",
|
||||
messages=[{"role": "user", "content": "describe this"}],
|
||||
)
|
||||
|
||||
# The resolved values must be passed, not the raw call_llm arguments
|
||||
call_args = mock_vision.call_args
|
||||
assert call_args.kwargs["provider"] == "my-resolved-provider"
|
||||
assert call_args.kwargs["model"] == "my-resolved-model"
|
||||
assert call_args.kwargs["base_url"] == "http://resolved"
|
||||
assert call_args.kwargs["api_key"] == "resolved-key"
|
||||
@@ -578,7 +578,7 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
|
||||
# After the probe detects a single model ("llm"), the flow asks
|
||||
# "Use this model? [Y/n]:" — confirm with Enter, then context length,
|
||||
# then display name.
|
||||
answers = iter(["http://localhost:8000", "local-key", "", "", ""])
|
||||
answers = iter(["http://localhost:8000", "local-key", "", "", "", ""])
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
|
||||
monkeypatch.setattr("getpass.getpass", lambda _prompt="": next(answers))
|
||||
|
||||
|
||||
107
tests/cli/test_cwd_env_respect.py
Normal file
107
tests/cli/test_cwd_env_respect.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Tests that load_cli_config() guards against lazy-import TERMINAL_CWD clobbering.
|
||||
|
||||
When the gateway resolves TERMINAL_CWD at startup and cli.py is later
|
||||
imported lazily (via delegate_tool → CLI_CONFIG), load_cli_config() must
|
||||
not overwrite the already-resolved value with os.getcwd().
|
||||
|
||||
config.yaml terminal.cwd is the canonical source of truth.
|
||||
.env TERMINAL_CWD and MESSAGING_CWD are deprecated.
|
||||
See issue #10817.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
# The sentinel values that mean "resolve at runtime"
|
||||
_CWD_PLACEHOLDERS = (".", "auto", "cwd")
|
||||
|
||||
|
||||
def _resolve_terminal_cwd(terminal_config: dict, defaults: dict, env: dict):
|
||||
"""Simulate the CWD resolution logic from load_cli_config().
|
||||
|
||||
This mirrors the code in cli.py that checks for a pre-resolved
|
||||
TERMINAL_CWD before falling back to os.getcwd().
|
||||
"""
|
||||
if terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
|
||||
_existing_cwd = env.get("TERMINAL_CWD", "")
|
||||
if _existing_cwd and _existing_cwd not in _CWD_PLACEHOLDERS and os.path.isabs(_existing_cwd):
|
||||
terminal_config["cwd"] = _existing_cwd
|
||||
defaults["terminal"]["cwd"] = _existing_cwd
|
||||
else:
|
||||
effective_backend = terminal_config.get("env_type", "local")
|
||||
if effective_backend == "local":
|
||||
terminal_config["cwd"] = "/fake/getcwd" # stand-in for os.getcwd()
|
||||
defaults["terminal"]["cwd"] = terminal_config["cwd"]
|
||||
else:
|
||||
terminal_config.pop("cwd", None)
|
||||
|
||||
# Simulate the bridging loop: write terminal_config["cwd"] to env
|
||||
_file_has_terminal = defaults.get("_file_has_terminal", False)
|
||||
if "cwd" in terminal_config:
|
||||
if _file_has_terminal or "TERMINAL_CWD" not in env:
|
||||
env["TERMINAL_CWD"] = str(terminal_config["cwd"])
|
||||
|
||||
return env.get("TERMINAL_CWD", "")
|
||||
|
||||
|
||||
class TestLazyImportGuard:
|
||||
"""TERMINAL_CWD resolved by gateway must survive a lazy cli.py import."""
|
||||
|
||||
def test_gateway_resolved_cwd_survives(self):
|
||||
"""Gateway set TERMINAL_CWD → lazy cli import must not clobber."""
|
||||
env = {"TERMINAL_CWD": "/home/user/workspace"}
|
||||
terminal_config = {"cwd": ".", "env_type": "local"}
|
||||
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}
|
||||
|
||||
result = _resolve_terminal_cwd(terminal_config, defaults, env)
|
||||
assert result == "/home/user/workspace"
|
||||
|
||||
def test_gateway_resolved_cwd_survives_with_file_terminal(self):
|
||||
"""Even when config.yaml has a terminal: section, resolved CWD survives."""
|
||||
env = {"TERMINAL_CWD": "/home/user/workspace"}
|
||||
terminal_config = {"cwd": ".", "env_type": "local"}
|
||||
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": True}
|
||||
|
||||
result = _resolve_terminal_cwd(terminal_config, defaults, env)
|
||||
assert result == "/home/user/workspace"
|
||||
|
||||
|
||||
class TestConfigCwdResolution:
|
||||
"""config.yaml terminal.cwd is the canonical source of truth."""
|
||||
|
||||
def test_explicit_config_cwd_wins(self):
|
||||
"""terminal.cwd: /explicit/path always wins."""
|
||||
env = {"TERMINAL_CWD": "/old/gateway/value"}
|
||||
terminal_config = {"cwd": "/explicit/path"}
|
||||
defaults = {"terminal": {"cwd": "/explicit/path"}, "_file_has_terminal": True}
|
||||
|
||||
result = _resolve_terminal_cwd(terminal_config, defaults, env)
|
||||
assert result == "/explicit/path"
|
||||
|
||||
def test_dot_cwd_resolves_to_getcwd_when_no_prior(self):
|
||||
"""With no pre-set TERMINAL_CWD, "." resolves to os.getcwd()."""
|
||||
env = {}
|
||||
terminal_config = {"cwd": "."}
|
||||
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}
|
||||
|
||||
result = _resolve_terminal_cwd(terminal_config, defaults, env)
|
||||
assert result == "/fake/getcwd"
|
||||
|
||||
def test_remote_backend_pops_cwd(self):
|
||||
"""Remote backend + placeholder cwd → popped for backend default."""
|
||||
env = {}
|
||||
terminal_config = {"cwd": ".", "env_type": "docker"}
|
||||
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}
|
||||
|
||||
result = _resolve_terminal_cwd(terminal_config, defaults, env)
|
||||
assert result == "" # cwd popped, no env var set
|
||||
|
||||
def test_remote_backend_with_prior_cwd_preserves(self):
|
||||
"""Remote backend + pre-resolved TERMINAL_CWD → adopted."""
|
||||
env = {"TERMINAL_CWD": "/project"}
|
||||
terminal_config = {"cwd": ".", "env_type": "docker"}
|
||||
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}
|
||||
|
||||
result = _resolve_terminal_cwd(terminal_config, defaults, env)
|
||||
assert result == "/project"
|
||||
@@ -138,7 +138,7 @@ class TestRunConversationSurrogateSanitization:
|
||||
mock_stream.return_value = mock_response
|
||||
mock_api.return_value = mock_response
|
||||
|
||||
agent = AIAgent(model="test/model", quiet_mode=True, skip_memory=True, skip_context_files=True)
|
||||
agent = AIAgent(model="test/model", api_key="test-key", base_url="http://localhost:1234/v1", quiet_mode=True, skip_memory=True, skip_context_files=True)
|
||||
agent.client = MagicMock()
|
||||
|
||||
# Pass a message with surrogates
|
||||
|
||||
@@ -675,7 +675,7 @@ class TestRunJobSessionPersistence:
|
||||
|
||||
def test_run_job_empty_response_returns_empty_not_placeholder(self, tmp_path):
|
||||
"""Empty final_response should stay empty for delivery logic (issue #2234).
|
||||
|
||||
|
||||
The placeholder '(No response generated)' should only appear in the
|
||||
output log, not in the returned final_response that's used for delivery.
|
||||
"""
|
||||
@@ -693,7 +693,7 @@ class TestRunJobSessionPersistence:
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
"api_key": "test-key",
|
||||
"api_key": "***",
|
||||
"base_url": "https://example.invalid/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
@@ -714,6 +714,43 @@ class TestRunJobSessionPersistence:
|
||||
# But the output log should show the placeholder
|
||||
assert "(No response generated)" in output
|
||||
|
||||
def test_tick_marks_empty_response_as_error(self, tmp_path):
|
||||
"""When run_job returns success=True but final_response is empty,
|
||||
tick() should mark the job as error so last_status != 'ok'.
|
||||
(issue #8585)
|
||||
"""
|
||||
from cron.scheduler import tick
|
||||
from cron.jobs import load_jobs, save_jobs
|
||||
|
||||
job = {
|
||||
"id": "empty-job",
|
||||
"name": "empty-test",
|
||||
"prompt": "do something",
|
||||
"schedule": "every 1h",
|
||||
"enabled": True,
|
||||
"next_run_at": "2020-01-01T00:00:00",
|
||||
"deliver": "local",
|
||||
"last_status": None,
|
||||
}
|
||||
|
||||
fake_db = MagicMock()
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler.get_due_jobs", return_value=[job]), \
|
||||
patch("cron.scheduler.advance_next_run"), \
|
||||
patch("cron.scheduler.mark_job_run") as mock_mark, \
|
||||
patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \
|
||||
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||
patch("cron.scheduler.run_job", return_value=(True, "output", "", None)):
|
||||
tick(verbose=False)
|
||||
|
||||
# Should be called with success=False because final_response is empty
|
||||
mock_mark.assert_called_once()
|
||||
call_args = mock_mark.call_args
|
||||
assert call_args[0][0] == "empty-job"
|
||||
assert call_args[0][1] is False # success should be False
|
||||
assert "empty" in call_args[0][2].lower() # error should mention empty
|
||||
|
||||
def test_run_job_sets_auto_delivery_env_from_dotenv_home_channel(self, tmp_path, monkeypatch):
|
||||
job = {
|
||||
"id": "test-job",
|
||||
|
||||
@@ -62,5 +62,86 @@ def _ensure_telegram_mock() -> None:
|
||||
sys.modules["telegram.error"] = mod.error
|
||||
|
||||
|
||||
def _ensure_discord_mock() -> None:
|
||||
"""Install a comprehensive discord mock in sys.modules.
|
||||
|
||||
Idempotent — skips when the real library is already imported.
|
||||
Uses ``sys.modules[name] = mod`` (overwrite) instead of
|
||||
``setdefault`` so it wins even if a partial/broken import already
|
||||
cached the module.
|
||||
|
||||
This mock is comprehensive — it includes **all** attributes needed by
|
||||
every gateway discord test file. Individual test files should call
|
||||
this function (it short-circuits when already present) rather than
|
||||
maintaining their own mock setup.
|
||||
"""
|
||||
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
|
||||
return # Real library is installed — nothing to mock
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
discord_mod = MagicMock()
|
||||
discord_mod.Intents.default.return_value = MagicMock()
|
||||
discord_mod.Client = MagicMock
|
||||
discord_mod.File = MagicMock
|
||||
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||
discord_mod.Thread = type("Thread", (), {})
|
||||
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||
discord_mod.Interaction = object
|
||||
discord_mod.Embed = MagicMock
|
||||
discord_mod.ui = SimpleNamespace(
|
||||
View=object,
|
||||
button=lambda *a, **k: (lambda fn: fn),
|
||||
Button=object,
|
||||
)
|
||||
discord_mod.ButtonStyle = SimpleNamespace(
|
||||
success=1, primary=2, secondary=2, danger=3,
|
||||
green=1, grey=2, blurple=2, red=3,
|
||||
)
|
||||
discord_mod.Color = SimpleNamespace(
|
||||
orange=lambda: 1, green=lambda: 2, blue=lambda: 3,
|
||||
red=lambda: 4, purple=lambda: 5,
|
||||
)
|
||||
|
||||
# app_commands — needed by _register_slash_commands auto-registration
|
||||
class _FakeGroup:
|
||||
def __init__(self, *, name, description, parent=None):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.parent = parent
|
||||
self._children: dict = {}
|
||||
if parent is not None:
|
||||
parent.add_command(self)
|
||||
|
||||
def add_command(self, cmd):
|
||||
self._children[cmd.name] = cmd
|
||||
|
||||
class _FakeCommand:
|
||||
def __init__(self, *, name, description, callback, parent=None):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.callback = callback
|
||||
self.parent = parent
|
||||
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
choices=lambda **kwargs: (lambda fn: fn),
|
||||
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
|
||||
Group=_FakeGroup,
|
||||
Command=_FakeCommand,
|
||||
)
|
||||
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
commands_mod.Bot = MagicMock
|
||||
ext_mod.commands = commands_mod
|
||||
|
||||
for name in ("discord", "discord.ext", "discord.ext.commands"):
|
||||
sys.modules[name] = discord_mod
|
||||
sys.modules["discord.ext"] = ext_mod
|
||||
sys.modules["discord.ext.commands"] = commands_mod
|
||||
|
||||
|
||||
# Run at collection time — before any test file's module-level imports.
|
||||
_ensure_telegram_mock()
|
||||
_ensure_discord_mock()
|
||||
|
||||
@@ -220,6 +220,8 @@ class TestRunBackgroundTask:
|
||||
with patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}), \
|
||||
patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_agent_instance = MagicMock()
|
||||
mock_agent_instance.shutdown_memory_provider = MagicMock()
|
||||
mock_agent_instance.close = MagicMock()
|
||||
mock_agent_instance.run_conversation.return_value = mock_result
|
||||
MockAgent.return_value = mock_agent_instance
|
||||
|
||||
@@ -231,6 +233,37 @@ class TestRunBackgroundTask:
|
||||
content = call_args[1].get("content", call_args[0][1] if len(call_args[0]) > 1 else "")
|
||||
assert "Background task complete" in content
|
||||
assert "Hello from background!" in content
|
||||
mock_agent_instance.shutdown_memory_provider.assert_called_once()
|
||||
mock_agent_instance.close.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_cleanup_runs_when_background_agent_raises(self):
|
||||
"""Temporary background agents must be cleaned up on error paths too."""
|
||||
runner = _make_runner()
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter.send = AsyncMock()
|
||||
runner.adapters[Platform.TELEGRAM] = mock_adapter
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
user_id="12345",
|
||||
chat_id="67890",
|
||||
user_name="testuser",
|
||||
)
|
||||
|
||||
with patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}), \
|
||||
patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_agent_instance = MagicMock()
|
||||
mock_agent_instance.shutdown_memory_provider = MagicMock()
|
||||
mock_agent_instance.close = MagicMock()
|
||||
mock_agent_instance.run_conversation.side_effect = RuntimeError("boom")
|
||||
MockAgent.return_value = mock_agent_instance
|
||||
|
||||
await runner._run_background_task("say hello", source, "bg_test")
|
||||
|
||||
mock_adapter.send.assert_called_once()
|
||||
mock_agent_instance.shutdown_memory_provider.assert_called_once()
|
||||
mock_agent_instance.close.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_sends_error_message(self):
|
||||
|
||||
@@ -62,6 +62,8 @@ async def test_compress_command_reports_noop_without_success_banner():
|
||||
history = _make_history()
|
||||
runner = _make_runner(history)
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.shutdown_memory_provider = MagicMock()
|
||||
agent_instance.close = MagicMock()
|
||||
agent_instance.context_compressor.protect_first_n = 0
|
||||
agent_instance.context_compressor._align_boundary_forward.return_value = 0
|
||||
agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2
|
||||
@@ -83,6 +85,8 @@ async def test_compress_command_reports_noop_without_success_banner():
|
||||
assert "No changes from compression" in result
|
||||
assert "Compressed:" not in result
|
||||
assert "Rough transcript estimate: ~100 tokens (unchanged)" in result
|
||||
agent_instance.shutdown_memory_provider.assert_called_once()
|
||||
agent_instance.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -95,6 +99,8 @@ async def test_compress_command_explains_when_token_estimate_rises():
|
||||
]
|
||||
runner = _make_runner(history)
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.shutdown_memory_provider = MagicMock()
|
||||
agent_instance.close = MagicMock()
|
||||
agent_instance.context_compressor.protect_first_n = 0
|
||||
agent_instance.context_compressor._align_boundary_forward.return_value = 0
|
||||
agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2
|
||||
@@ -119,3 +125,5 @@ async def test_compress_command_explains_when_token_estimate_rises():
|
||||
assert "Compressed: 4 → 3 messages" in result
|
||||
assert "Rough transcript estimate: ~100 → ~120 tokens" in result
|
||||
assert "denser summaries" in result
|
||||
agent_instance.shutdown_memory_provider.assert_called_once()
|
||||
agent_instance.close.assert_called_once()
|
||||
|
||||
@@ -37,6 +37,10 @@ def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None):
|
||||
for cfg_key, env_var in terminal_env_map.items():
|
||||
if cfg_key in terminal_cfg:
|
||||
val = terminal_cfg[cfg_key]
|
||||
# Skip cwd placeholder values — don't overwrite already-resolved
|
||||
# TERMINAL_CWD. Mirrors the fix in gateway/run.py.
|
||||
if cfg_key == "cwd" and str(val) in (".", "auto", "cwd"):
|
||||
continue
|
||||
if isinstance(val, list):
|
||||
env[env_var] = json.dumps(val)
|
||||
else:
|
||||
@@ -146,3 +150,58 @@ class TestTopLevelCwdAlias:
|
||||
cfg = {"cwd": "/from/config"}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/from/env"})
|
||||
assert result["TERMINAL_CWD"] == "/from/config"
|
||||
|
||||
|
||||
class TestNestedTerminalCwdPlaceholderSkip:
|
||||
"""terminal.cwd placeholder values must not clobber TERMINAL_CWD.
|
||||
|
||||
When config.yaml has terminal.cwd: "." (or "auto"/"cwd"), the gateway
|
||||
config bridge should NOT write that placeholder to TERMINAL_CWD.
|
||||
This prevents .env or MESSAGING_CWD values from being overwritten.
|
||||
See issues #10225, #4672, #10817.
|
||||
"""
|
||||
|
||||
def test_terminal_dot_cwd_does_not_clobber_env(self):
|
||||
"""terminal.cwd: '.' should not overwrite a pre-set TERMINAL_CWD."""
|
||||
cfg = {"terminal": {"cwd": "."}}
|
||||
result = _simulate_config_bridge(cfg, {"TERMINAL_CWD": "/my/project"})
|
||||
assert result["TERMINAL_CWD"] == "/my/project"
|
||||
|
||||
def test_terminal_auto_cwd_does_not_clobber_env(self):
|
||||
cfg = {"terminal": {"cwd": "auto"}}
|
||||
result = _simulate_config_bridge(cfg, {"TERMINAL_CWD": "/my/project"})
|
||||
assert result["TERMINAL_CWD"] == "/my/project"
|
||||
|
||||
def test_terminal_cwd_keyword_does_not_clobber_env(self):
|
||||
cfg = {"terminal": {"cwd": "cwd"}}
|
||||
result = _simulate_config_bridge(cfg, {"TERMINAL_CWD": "/my/project"})
|
||||
assert result["TERMINAL_CWD"] == "/my/project"
|
||||
|
||||
def test_terminal_explicit_cwd_does_override(self):
|
||||
"""terminal.cwd: '/explicit/path' SHOULD override TERMINAL_CWD."""
|
||||
cfg = {"terminal": {"cwd": "/explicit/path"}}
|
||||
result = _simulate_config_bridge(cfg, {"TERMINAL_CWD": "/old/value"})
|
||||
assert result["TERMINAL_CWD"] == "/explicit/path"
|
||||
|
||||
def test_terminal_dot_cwd_falls_back_to_messaging_cwd(self):
|
||||
"""terminal.cwd: '.' with no TERMINAL_CWD should fall to MESSAGING_CWD."""
|
||||
cfg = {"terminal": {"cwd": "."}}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/from/env"})
|
||||
assert result["TERMINAL_CWD"] == "/from/env"
|
||||
|
||||
def test_terminal_dot_cwd_and_messaging_cwd_both_set(self):
|
||||
"""Pre-set TERMINAL_CWD from .env wins over terminal.cwd: '.'."""
|
||||
cfg = {"terminal": {"cwd": ".", "backend": "local"}}
|
||||
result = _simulate_config_bridge(cfg, {
|
||||
"TERMINAL_CWD": "/my/project",
|
||||
"MESSAGING_CWD": "/fallback",
|
||||
})
|
||||
assert result["TERMINAL_CWD"] == "/my/project"
|
||||
|
||||
def test_non_cwd_terminal_keys_still_bridge(self):
|
||||
"""Other terminal config keys (backend, timeout) should still bridge normally."""
|
||||
cfg = {"terminal": {"cwd": ".", "backend": "docker", "timeout": "300"}}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/from/env"})
|
||||
assert result["TERMINAL_ENV"] == "docker"
|
||||
assert result["TERMINAL_TIMEOUT"] == "300"
|
||||
assert result["TERMINAL_CWD"] == "/from/env"
|
||||
|
||||
@@ -284,9 +284,20 @@ class TestEnvVarOverride:
|
||||
# Tests for reply_to_text extraction in _handle_message
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
class FakeDMChannel:
|
||||
# Build FakeDMChannel as a subclass of the real discord.DMChannel when the
|
||||
# library is installed — this guarantees isinstance() checks pass in
|
||||
# production code regardless of test ordering or monkeypatch state.
|
||||
try:
|
||||
import discord as _discord_lib
|
||||
_DMChannelBase = _discord_lib.DMChannel
|
||||
except (ImportError, AttributeError):
|
||||
_DMChannelBase = object
|
||||
|
||||
|
||||
class FakeDMChannel(_DMChannelBase):
|
||||
"""Minimal DM channel stub (skips mention / channel-allow checks)."""
|
||||
def __init__(self, channel_id: int = 100, name: str = "dm"):
|
||||
# Do NOT call super().__init__() — real DMChannel requires State
|
||||
self.id = channel_id
|
||||
self.name = name
|
||||
|
||||
@@ -309,10 +320,6 @@ def _make_message(*, content: str = "hi", reference=None):
|
||||
@pytest.fixture
|
||||
def reply_text_adapter(monkeypatch):
|
||||
"""DiscordAdapter wired for _handle_message → handle_message capture."""
|
||||
import gateway.platforms.discord as discord_platform
|
||||
|
||||
monkeypatch.setattr(discord_platform.discord, "DMChannel", FakeDMChannel, raising=False)
|
||||
|
||||
config = PlatformConfig(enabled=True, token="fake-token")
|
||||
adapter = DiscordAdapter(config)
|
||||
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
|
||||
|
||||
@@ -202,6 +202,22 @@ class TestFlushAgentSilenced:
|
||||
sys.stdout = old_stdout
|
||||
assert buf.getvalue() == "", "no-op print_fn spinner must not write to stdout"
|
||||
|
||||
def test_flush_agent_closes_resources_after_run(self, monkeypatch):
|
||||
"""Memory flush should close temporary agent resources after the turn."""
|
||||
runner, tmp_agent, _ = _make_flush_context(monkeypatch)
|
||||
tmp_agent.shutdown_memory_provider = MagicMock()
|
||||
tmp_agent.close = MagicMock()
|
||||
|
||||
with (
|
||||
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "k"}),
|
||||
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
|
||||
patch.dict("sys.modules", {"tools.memory_tool": MagicMock(get_memory_dir=lambda: Path("/nonexistent"))}),
|
||||
):
|
||||
runner._flush_memories_for_session("session_cleanup")
|
||||
|
||||
tmp_agent.shutdown_memory_provider.assert_called_once()
|
||||
tmp_agent.close.assert_called_once()
|
||||
|
||||
|
||||
class TestFlushPromptStructure:
|
||||
"""Verify the flush prompt retains its core instructions."""
|
||||
|
||||
42
tests/gateway/test_pending_event_none.py
Normal file
42
tests/gateway/test_pending_event_none.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Tests for the pending_event None guard in recursive _run_agent calls.
|
||||
|
||||
When pending_event is None (Path B: pending comes from interrupt_message),
|
||||
accessing pending_event.channel_prompt previously raised AttributeError.
|
||||
This verifies the fix: channel_prompt is captured inside the
|
||||
`if pending_event is not None:` block and falls back to None otherwise.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
def _extract_channel_prompt(pending_event):
|
||||
"""Reproduce the fixed logic from gateway/run.py.
|
||||
|
||||
Mirrors the variable-capture pattern used before the recursive
|
||||
_run_agent call so we can test both paths without a full runner.
|
||||
"""
|
||||
next_channel_prompt = None
|
||||
if pending_event is not None:
|
||||
next_channel_prompt = getattr(pending_event, "channel_prompt", None)
|
||||
return next_channel_prompt
|
||||
|
||||
|
||||
class TestPendingEventNoneChannelPrompt:
|
||||
"""Guard against AttributeError when pending_event is None."""
|
||||
|
||||
def test_none_pending_event_returns_none_channel_prompt(self):
|
||||
"""Path B: pending_event is None — must not raise AttributeError."""
|
||||
result = _extract_channel_prompt(None)
|
||||
assert result is None
|
||||
|
||||
def test_pending_event_with_channel_prompt_passes_through(self):
|
||||
"""Path A: pending_event present — channel_prompt is forwarded."""
|
||||
event = SimpleNamespace(channel_prompt="You are a helpful bot.")
|
||||
result = _extract_channel_prompt(event)
|
||||
assert result == "You are a helpful bot."
|
||||
|
||||
def test_pending_event_without_channel_prompt_returns_none(self):
|
||||
"""Path A: pending_event present but has no channel_prompt attribute."""
|
||||
event = SimpleNamespace()
|
||||
result = _extract_channel_prompt(event)
|
||||
assert result is None
|
||||
@@ -305,10 +305,15 @@ async def test_session_hygiene_messages_stay_in_originating_topic(monkeypatch, t
|
||||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||||
|
||||
class FakeCompressAgent:
|
||||
last_instance = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.model = kwargs.get("model")
|
||||
self.session_id = kwargs.get("session_id", "fake-session")
|
||||
self._print_fn = None
|
||||
self.shutdown_memory_provider = MagicMock()
|
||||
self.close = MagicMock()
|
||||
type(self).last_instance = self
|
||||
|
||||
def _compress_context(self, messages, *_args, **_kwargs):
|
||||
# Simulate real _compress_context: create a new session_id
|
||||
@@ -385,3 +390,6 @@ async def test_session_hygiene_messages_stay_in_originating_topic(monkeypatch, t
|
||||
# Compression warnings are no longer sent to users — compression
|
||||
# happens silently with server-side logging only.
|
||||
assert len(adapter.sent) == 0
|
||||
assert FakeCompressAgent.last_instance is not None
|
||||
FakeCompressAgent.last_instance.shutdown_memory_provider.assert_called_once()
|
||||
FakeCompressAgent.last_instance.close.assert_called_once()
|
||||
|
||||
@@ -606,6 +606,56 @@ class TestSegmentBreakOnToolBoundary:
|
||||
assert sent_texts[0].startswith(prefix)
|
||||
assert sum(len(t) for t in sent_texts[1:]) == len(tail)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_final_sends_full_text_at_tool_boundary(self):
|
||||
"""After a tool call, the streamed prefix is stale (from the pre-tool
|
||||
segment). _send_fallback_final must still send the post-tool response
|
||||
even when continuation_text calculates as empty (#10807)."""
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True, message_id="msg_1"),
|
||||
)
|
||||
adapter.edit_message = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True),
|
||||
)
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
|
||||
# Simulate a pre-tool streamed segment that becomes the visible prefix
|
||||
pre_tool_text = "I'll run that code now."
|
||||
consumer.on_delta(pre_tool_text)
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# After the tool call, the model returns a SHORT final response that
|
||||
# does NOT start with the pre-tool prefix. The continuation calculator
|
||||
# would return empty (no prefix match → full text returned, but if the
|
||||
# streaming edit already showed pre_tool_text, the prefix-based logic
|
||||
# wrongly matches). Simulate this by setting _last_sent_text to the
|
||||
# pre-tool content, then finishing with different post-tool content.
|
||||
consumer._last_sent_text = pre_tool_text
|
||||
post_tool_response = "⏰ Script timed out after 30s and was killed."
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
# The fallback should send the post-tool response via
|
||||
# _send_fallback_final.
|
||||
await consumer._send_fallback_final(post_tool_response)
|
||||
|
||||
# Verify the final text was sent (not silently dropped)
|
||||
sent = False
|
||||
for call in adapter.send.call_args_list:
|
||||
content = call[1].get("content", call[0][0] if call[0] else "")
|
||||
if "timed out" in str(content):
|
||||
sent = True
|
||||
break
|
||||
assert sent, (
|
||||
"Post-tool timeout response was silently dropped by "
|
||||
"_send_fallback_final — the #10807 fix should prevent this"
|
||||
)
|
||||
|
||||
|
||||
class TestInterimCommentaryMessages:
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -322,7 +322,7 @@ class TestFallbackTransportInit:
|
||||
seen_kwargs.append(kwargs.copy())
|
||||
return FakeTransport([], {})
|
||||
|
||||
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy"):
|
||||
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy", "TELEGRAM_PROXY"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
|
||||
monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", factory)
|
||||
|
||||
64
tests/hermes_cli/test_deprecated_cwd_warning.py
Normal file
64
tests/hermes_cli/test_deprecated_cwd_warning.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Tests for warn_deprecated_cwd_env_vars() migration warning."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDeprecatedCwdWarning:
|
||||
"""Warn when MESSAGING_CWD or TERMINAL_CWD is set in .env."""
|
||||
|
||||
def test_messaging_cwd_triggers_warning(self, monkeypatch, capsys):
|
||||
monkeypatch.setenv("MESSAGING_CWD", "/some/path")
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
|
||||
from hermes_cli.config import warn_deprecated_cwd_env_vars
|
||||
warn_deprecated_cwd_env_vars(config={})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "MESSAGING_CWD" in captured.err
|
||||
assert "deprecated" in captured.err.lower()
|
||||
assert "config.yaml" in captured.err
|
||||
|
||||
def test_terminal_cwd_triggers_warning_when_config_placeholder(self, monkeypatch, capsys):
|
||||
monkeypatch.setenv("TERMINAL_CWD", "/project")
|
||||
monkeypatch.delenv("MESSAGING_CWD", raising=False)
|
||||
|
||||
from hermes_cli.config import warn_deprecated_cwd_env_vars
|
||||
# config has placeholder cwd → TERMINAL_CWD likely from .env
|
||||
warn_deprecated_cwd_env_vars(config={"terminal": {"cwd": "."}})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "TERMINAL_CWD" in captured.err
|
||||
assert "deprecated" in captured.err.lower()
|
||||
|
||||
def test_no_warning_when_config_has_explicit_cwd(self, monkeypatch, capsys):
|
||||
monkeypatch.setenv("TERMINAL_CWD", "/project")
|
||||
monkeypatch.delenv("MESSAGING_CWD", raising=False)
|
||||
|
||||
from hermes_cli.config import warn_deprecated_cwd_env_vars
|
||||
# config has explicit cwd → TERMINAL_CWD could be from config bridge
|
||||
warn_deprecated_cwd_env_vars(config={"terminal": {"cwd": "/project"}})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "TERMINAL_CWD" not in captured.err
|
||||
|
||||
def test_no_warning_when_env_clean(self, monkeypatch, capsys):
|
||||
monkeypatch.delenv("MESSAGING_CWD", raising=False)
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
|
||||
from hermes_cli.config import warn_deprecated_cwd_env_vars
|
||||
warn_deprecated_cwd_env_vars(config={})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.err == ""
|
||||
|
||||
def test_both_deprecated_vars_warn(self, monkeypatch, capsys):
|
||||
monkeypatch.setenv("MESSAGING_CWD", "/msg/path")
|
||||
monkeypatch.setenv("TERMINAL_CWD", "/term/path")
|
||||
|
||||
from hermes_cli.config import warn_deprecated_cwd_env_vars
|
||||
warn_deprecated_cwd_env_vars(config={})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "MESSAGING_CWD" in captured.err
|
||||
assert "TERMINAL_CWD" in captured.err
|
||||
157
tests/hermes_cli/test_memory_reset.py
Normal file
157
tests/hermes_cli/test_memory_reset.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Tests for the `hermes memory reset` CLI command.
|
||||
|
||||
Covers:
|
||||
- Reset both stores (MEMORY.md + USER.md)
|
||||
- Reset individual stores (--target memory / --target user)
|
||||
- Skip confirmation with --yes
|
||||
- Graceful handling when no memory files exist
|
||||
- Profile-scoped reset (uses HERMES_HOME)
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_env(tmp_path, monkeypatch):
|
||||
"""Set up a fake HERMES_HOME with memory files."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
memories = hermes_home / "memories"
|
||||
memories.mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
# Create sample memory files
|
||||
(memories / "MEMORY.md").write_text(
|
||||
"§\nHermes repo is at ~/.hermes/hermes-agent\n§\nUser prefers dark themes",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(memories / "USER.md").write_text(
|
||||
"§\nUser is Teknium\n§\nTimezone: US Pacific",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return hermes_home, memories
|
||||
|
||||
|
||||
def _run_memory_reset(target="all", yes=False, monkeypatch=None, confirm_input="no"):
|
||||
"""Invoke the memory reset logic from cmd_memory in main.py.
|
||||
|
||||
Simulates what happens when `hermes memory reset` is run.
|
||||
"""
|
||||
from hermes_constants import get_hermes_home, display_hermes_home
|
||||
|
||||
mem_dir = get_hermes_home() / "memories"
|
||||
files_to_reset = []
|
||||
if target in ("all", "memory"):
|
||||
files_to_reset.append(("MEMORY.md", "agent notes"))
|
||||
if target in ("all", "user"):
|
||||
files_to_reset.append(("USER.md", "user profile"))
|
||||
|
||||
existing = [(f, desc) for f, desc in files_to_reset if (mem_dir / f).exists()]
|
||||
if not existing:
|
||||
return "nothing"
|
||||
|
||||
if not yes:
|
||||
if confirm_input != "yes":
|
||||
return "cancelled"
|
||||
|
||||
for f, desc in existing:
|
||||
(mem_dir / f).unlink()
|
||||
|
||||
return "deleted"
|
||||
|
||||
|
||||
class TestMemoryReset:
|
||||
"""Tests for `hermes memory reset` subcommand."""
|
||||
|
||||
def test_reset_all_with_yes_flag(self, memory_env):
|
||||
"""--yes flag should skip confirmation and delete both files."""
|
||||
hermes_home, memories = memory_env
|
||||
assert (memories / "MEMORY.md").exists()
|
||||
assert (memories / "USER.md").exists()
|
||||
|
||||
result = _run_memory_reset(target="all", yes=True)
|
||||
assert result == "deleted"
|
||||
assert not (memories / "MEMORY.md").exists()
|
||||
assert not (memories / "USER.md").exists()
|
||||
|
||||
def test_reset_memory_only(self, memory_env):
|
||||
"""--target memory should only delete MEMORY.md."""
|
||||
hermes_home, memories = memory_env
|
||||
|
||||
result = _run_memory_reset(target="memory", yes=True)
|
||||
assert result == "deleted"
|
||||
assert not (memories / "MEMORY.md").exists()
|
||||
assert (memories / "USER.md").exists()
|
||||
|
||||
def test_reset_user_only(self, memory_env):
|
||||
"""--target user should only delete USER.md."""
|
||||
hermes_home, memories = memory_env
|
||||
|
||||
result = _run_memory_reset(target="user", yes=True)
|
||||
assert result == "deleted"
|
||||
assert (memories / "MEMORY.md").exists()
|
||||
assert not (memories / "USER.md").exists()
|
||||
|
||||
def test_reset_no_files_exist(self, tmp_path, monkeypatch):
|
||||
"""Should return 'nothing' when no memory files exist."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
(hermes_home / "memories").mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
result = _run_memory_reset(target="all", yes=True)
|
||||
assert result == "nothing"
|
||||
|
||||
def test_reset_confirmation_denied(self, memory_env):
|
||||
"""Without --yes and without typing 'yes', should be cancelled."""
|
||||
hermes_home, memories = memory_env
|
||||
|
||||
result = _run_memory_reset(target="all", yes=False, confirm_input="no")
|
||||
assert result == "cancelled"
|
||||
# Files should still exist
|
||||
assert (memories / "MEMORY.md").exists()
|
||||
assert (memories / "USER.md").exists()
|
||||
|
||||
def test_reset_confirmation_accepted(self, memory_env):
|
||||
"""Typing 'yes' should proceed with deletion."""
|
||||
hermes_home, memories = memory_env
|
||||
|
||||
result = _run_memory_reset(target="all", yes=False, confirm_input="yes")
|
||||
assert result == "deleted"
|
||||
assert not (memories / "MEMORY.md").exists()
|
||||
assert not (memories / "USER.md").exists()
|
||||
|
||||
def test_reset_profile_scoped(self, tmp_path, monkeypatch):
|
||||
"""Reset should work on the active profile's HERMES_HOME."""
|
||||
profile_home = tmp_path / "profiles" / "myprofile"
|
||||
memories = profile_home / "memories"
|
||||
memories.mkdir(parents=True)
|
||||
(memories / "MEMORY.md").write_text("profile memory", encoding="utf-8")
|
||||
(memories / "USER.md").write_text("profile user", encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_home))
|
||||
|
||||
result = _run_memory_reset(target="all", yes=True)
|
||||
assert result == "deleted"
|
||||
assert not (memories / "MEMORY.md").exists()
|
||||
assert not (memories / "USER.md").exists()
|
||||
|
||||
def test_reset_partial_files(self, memory_env):
|
||||
"""Reset should work when only one memory file exists."""
|
||||
hermes_home, memories = memory_env
|
||||
(memories / "USER.md").unlink()
|
||||
|
||||
result = _run_memory_reset(target="all", yes=True)
|
||||
assert result == "deleted"
|
||||
assert not (memories / "MEMORY.md").exists()
|
||||
|
||||
def test_reset_empty_memories_dir(self, tmp_path, monkeypatch):
|
||||
"""No memories dir at all should report nothing."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir(parents=True)
|
||||
# No memories dir
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
# The memories dir won't exist; get_hermes_home() / "memories" won't have files
|
||||
result = _run_memory_reset(target="all", yes=True)
|
||||
assert result == "nothing"
|
||||
@@ -114,6 +114,65 @@ class TestOllamaCloudModelCatalog:
|
||||
assert "ollama-cloud" in _PROVIDER_LABELS
|
||||
assert _PROVIDER_LABELS["ollama-cloud"] == "Ollama Cloud"
|
||||
|
||||
def test_provider_model_ids_returns_dynamic_models(self, tmp_path, monkeypatch):
|
||||
"""provider_model_ids('ollama-cloud') should call fetch_ollama_cloud_models()."""
|
||||
from hermes_cli.models import provider_model_ids
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "test-key")
|
||||
|
||||
mock_mdev = {
|
||||
"ollama-cloud": {
|
||||
"models": {
|
||||
"qwen3.5:397b": {"tool_call": True},
|
||||
"glm-5": {"tool_call": True},
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["qwen3.5:397b"]), \
|
||||
patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev):
|
||||
result = provider_model_ids("ollama-cloud", force_refresh=True)
|
||||
|
||||
assert len(result) > 0
|
||||
assert "qwen3.5:397b" in result
|
||||
|
||||
|
||||
# ── Model Picker (list_authenticated_providers) ──
|
||||
|
||||
class TestOllamaCloudModelPicker:
|
||||
def test_ollama_cloud_shows_model_count(self, tmp_path, monkeypatch):
|
||||
"""Ollama Cloud should show non-zero model count in provider picker."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "test-key")
|
||||
|
||||
mock_mdev = {
|
||||
"ollama-cloud": {
|
||||
"models": {
|
||||
"qwen3.5:397b": {"tool_call": True},
|
||||
"glm-5": {"tool_call": True},
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["qwen3.5:397b"]), \
|
||||
patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev):
|
||||
providers = list_authenticated_providers(current_provider="ollama-cloud")
|
||||
|
||||
ollama = next((p for p in providers if p["slug"] == "ollama-cloud"), None)
|
||||
assert ollama is not None, "ollama-cloud should appear when OLLAMA_API_KEY is set"
|
||||
assert ollama["total_models"] > 0, "ollama-cloud should show non-zero model count"
|
||||
|
||||
def test_ollama_cloud_not_shown_without_creds(self, monkeypatch):
|
||||
"""Ollama Cloud should not appear without credentials."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
|
||||
|
||||
providers = list_authenticated_providers(current_provider="openrouter")
|
||||
ollama = next((p for p in providers if p["slug"] == "ollama-cloud"), None)
|
||||
assert ollama is None, "ollama-cloud should not appear without OLLAMA_API_KEY"
|
||||
|
||||
|
||||
# ── Merged Model Discovery ──
|
||||
|
||||
|
||||
@@ -152,6 +152,24 @@ class TestSkinManagement:
|
||||
init_skin_from_config({})
|
||||
assert get_active_skin_name() == "default"
|
||||
|
||||
def test_init_skin_from_null_display(self):
|
||||
"""display: null should fall back to default, not crash."""
|
||||
from hermes_cli.skin_engine import init_skin_from_config, get_active_skin_name
|
||||
init_skin_from_config({"display": None})
|
||||
assert get_active_skin_name() == "default"
|
||||
|
||||
def test_init_skin_from_non_dict_display(self):
|
||||
"""display: <non-dict> should fall back to default."""
|
||||
from hermes_cli.skin_engine import init_skin_from_config, get_active_skin_name
|
||||
init_skin_from_config({"display": "invalid"})
|
||||
assert get_active_skin_name() == "default"
|
||||
|
||||
init_skin_from_config({"display": 42})
|
||||
assert get_active_skin_name() == "default"
|
||||
|
||||
init_skin_from_config({"display": []})
|
||||
assert get_active_skin_name() == "default"
|
||||
|
||||
|
||||
class TestUserSkins:
|
||||
def test_load_user_skin_from_yaml(self, tmp_path, monkeypatch):
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
"""Tests for context pressure warnings (user-facing, not injected into messages).
|
||||
|
||||
Covers:
|
||||
- Display formatting (CLI and gateway variants)
|
||||
- Flag tracking and threshold logic on AIAgent
|
||||
- Flag reset after compression
|
||||
- status_callback invocation
|
||||
"""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.display import format_context_pressure, format_context_pressure_gateway
|
||||
from run_agent import AIAgent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Display formatting tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatContextPressure:
|
||||
"""CLI context pressure display (agent/display.py).
|
||||
|
||||
The bar shows progress toward the compaction threshold, not the
|
||||
raw context window. 60% = 60% of the way to compaction.
|
||||
"""
|
||||
|
||||
def test_80_percent_uses_warning_icon(self):
|
||||
line = format_context_pressure(0.80, 100_000, 0.50)
|
||||
assert "⚠" in line
|
||||
assert "80% to compaction" in line
|
||||
|
||||
def test_90_percent_uses_warning_icon(self):
|
||||
line = format_context_pressure(0.90, 100_000, 0.50)
|
||||
assert "⚠" in line
|
||||
assert "90% to compaction" in line
|
||||
|
||||
def test_bar_length_scales_with_progress(self):
|
||||
line_80 = format_context_pressure(0.80, 100_000, 0.50)
|
||||
line_95 = format_context_pressure(0.95, 100_000, 0.50)
|
||||
assert line_95.count("▰") > line_80.count("▰")
|
||||
|
||||
def test_shows_threshold_tokens(self):
|
||||
line = format_context_pressure(0.80, 100_000, 0.50)
|
||||
assert "100k" in line
|
||||
|
||||
def test_small_threshold(self):
|
||||
line = format_context_pressure(0.80, 500, 0.50)
|
||||
assert "500" in line
|
||||
|
||||
def test_shows_threshold_percent(self):
|
||||
line = format_context_pressure(0.80, 100_000, 0.50)
|
||||
assert "50%" in line
|
||||
|
||||
def test_approaching_hint(self):
|
||||
line = format_context_pressure(0.80, 100_000, 0.50)
|
||||
assert "compaction approaching" in line
|
||||
|
||||
def test_no_compaction_when_disabled(self):
|
||||
line = format_context_pressure(0.85, 100_000, 0.50, compression_enabled=False)
|
||||
assert "no auto-compaction" in line
|
||||
|
||||
def test_returns_string(self):
|
||||
result = format_context_pressure(0.65, 128_000, 0.50)
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_over_100_percent_capped(self):
|
||||
"""Progress > 1.0 should cap both bar and percentage text at 100%."""
|
||||
line = format_context_pressure(1.05, 100_000, 0.50)
|
||||
assert "▰" in line
|
||||
assert line.count("▰") == 20
|
||||
assert "100%" in line
|
||||
assert "105%" not in line
|
||||
|
||||
|
||||
class TestFormatContextPressureGateway:
|
||||
"""Gateway (plain text) context pressure display."""
|
||||
|
||||
def test_80_percent_warning(self):
|
||||
msg = format_context_pressure_gateway(0.80, 0.50)
|
||||
assert "80% to compaction" in msg
|
||||
assert "50%" in msg
|
||||
|
||||
def test_90_percent_warning(self):
|
||||
msg = format_context_pressure_gateway(0.90, 0.50)
|
||||
assert "90% to compaction" in msg
|
||||
assert "approaching" in msg
|
||||
|
||||
def test_no_compaction_warning(self):
|
||||
msg = format_context_pressure_gateway(0.85, 0.50, compression_enabled=False)
|
||||
assert "disabled" in msg
|
||||
|
||||
def test_no_ansi_codes(self):
|
||||
msg = format_context_pressure_gateway(0.80, 0.50)
|
||||
assert "\033[" not in msg
|
||||
|
||||
def test_has_progress_bar(self):
|
||||
msg = format_context_pressure_gateway(0.80, 0.50)
|
||||
assert "▰" in msg
|
||||
|
||||
def test_over_100_percent_capped(self):
|
||||
"""Progress > 1.0 should cap percentage text at 100%."""
|
||||
msg = format_context_pressure_gateway(1.09, 0.50)
|
||||
assert "100% to compaction" in msg
|
||||
assert "109%" not in msg
|
||||
assert msg.count("▰") == 20
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AIAgent context pressure flag tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_tool_defs(*names):
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": n,
|
||||
"description": f"{n} tool",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
for n in names
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def agent():
|
||||
"""Minimal AIAgent with mocked internals."""
|
||||
with (
|
||||
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("run_agent.OpenAI"),
|
||||
):
|
||||
a = AIAgent(
|
||||
api_key="test-key-1234567890",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
a.client = MagicMock()
|
||||
return a
|
||||
|
||||
|
||||
class TestContextPressureFlags:
|
||||
"""Context pressure warning flag tracking on AIAgent."""
|
||||
|
||||
def test_flag_initialized_zero(self, agent):
|
||||
assert agent._context_pressure_warned_at == 0.0
|
||||
|
||||
def test_emit_calls_status_callback(self, agent):
|
||||
"""status_callback should be invoked with event type and message."""
|
||||
cb = MagicMock()
|
||||
agent.status_callback = cb
|
||||
|
||||
compressor = MagicMock()
|
||||
compressor.context_length = 200_000
|
||||
compressor.threshold_tokens = 100_000 # 50%
|
||||
|
||||
agent._emit_context_pressure(0.85, compressor)
|
||||
|
||||
cb.assert_called_once()
|
||||
args = cb.call_args[0]
|
||||
assert args[0] == "context_pressure"
|
||||
assert "85% to compaction" in args[1]
|
||||
|
||||
def test_emit_no_callback_no_crash(self, agent):
|
||||
"""No status_callback set — should not crash."""
|
||||
agent.status_callback = None
|
||||
|
||||
compressor = MagicMock()
|
||||
compressor.context_length = 200_000
|
||||
compressor.threshold_tokens = 100_000
|
||||
|
||||
# Should not raise
|
||||
agent._emit_context_pressure(0.60, compressor)
|
||||
|
||||
def test_emit_prints_for_cli_platform(self, agent, capsys):
|
||||
"""CLI platform should always print context pressure, even in quiet_mode."""
|
||||
agent.quiet_mode = True
|
||||
agent.platform = "cli"
|
||||
agent.status_callback = None
|
||||
|
||||
compressor = MagicMock()
|
||||
compressor.context_length = 200_000
|
||||
compressor.threshold_tokens = 100_000
|
||||
|
||||
agent._emit_context_pressure(0.85, compressor)
|
||||
captured = capsys.readouterr()
|
||||
assert "▰" in captured.out
|
||||
assert "to compaction" in captured.out
|
||||
|
||||
def test_emit_skips_print_for_gateway_platform(self, agent, capsys):
|
||||
"""Gateway platforms get the callback, not CLI print."""
|
||||
agent.platform = "telegram"
|
||||
agent.status_callback = None
|
||||
|
||||
compressor = MagicMock()
|
||||
compressor.context_length = 200_000
|
||||
compressor.threshold_tokens = 100_000
|
||||
|
||||
agent._emit_context_pressure(0.85, compressor)
|
||||
captured = capsys.readouterr()
|
||||
assert "▰" not in captured.out
|
||||
|
||||
def test_flag_reset_on_compression(self, agent):
|
||||
"""After _compress_context, context pressure flag should reset."""
|
||||
agent._context_pressure_warned_at = 0.85
|
||||
agent.compression_enabled = True
|
||||
|
||||
agent.context_compressor = MagicMock()
|
||||
agent.context_compressor.compress.return_value = [
|
||||
{"role": "user", "content": "Summary of conversation so far."}
|
||||
]
|
||||
agent.context_compressor.context_length = 200_000
|
||||
agent.context_compressor.threshold_tokens = 100_000
|
||||
agent.context_compressor.compression_count = 1
|
||||
|
||||
agent._todo_store = MagicMock()
|
||||
agent._todo_store.format_for_injection.return_value = None
|
||||
|
||||
agent._build_system_prompt = MagicMock(return_value="system prompt")
|
||||
agent._cached_system_prompt = "old system prompt"
|
||||
agent._session_db = None
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi there"},
|
||||
]
|
||||
agent._compress_context(messages, "system prompt")
|
||||
|
||||
assert agent._context_pressure_warned_at == 0.0
|
||||
|
||||
def test_emit_callback_error_handled(self, agent):
|
||||
"""If status_callback raises, it should be caught gracefully."""
|
||||
cb = MagicMock(side_effect=RuntimeError("callback boom"))
|
||||
agent.status_callback = cb
|
||||
|
||||
compressor = MagicMock()
|
||||
compressor.context_length = 200_000
|
||||
compressor.threshold_tokens = 100_000
|
||||
|
||||
# Should not raise
|
||||
agent._emit_context_pressure(0.85, compressor)
|
||||
|
||||
def test_tiered_reemits_at_95(self, agent):
|
||||
"""Warning fires at 85%, then fires again when crossing 95%."""
|
||||
agent._context_pressure_warned_at = 0.85
|
||||
# Simulate crossing 95%: the tier (0.95) > warned_at (0.85)
|
||||
assert 0.95 > agent._context_pressure_warned_at
|
||||
# After emission at 95%, the tier should update
|
||||
agent._context_pressure_warned_at = 0.95
|
||||
assert agent._context_pressure_warned_at == 0.95
|
||||
|
||||
def test_tiered_no_double_emit_at_same_level(self, agent):
|
||||
"""Once warned at 85%, further 85%+ readings don't re-warn."""
|
||||
agent._context_pressure_warned_at = 0.85
|
||||
# At 88%, tier is 0.85, which is NOT > warned_at (0.85)
|
||||
_warn_tier = 0.85 if 0.88 >= 0.85 else 0.0
|
||||
assert not (_warn_tier > agent._context_pressure_warned_at)
|
||||
|
||||
def test_flag_not_reset_when_compression_insufficient(self, agent):
|
||||
"""When compression can't drop below 85%, keep the flag set."""
|
||||
agent._context_pressure_warned_at = 0.85
|
||||
agent.compression_enabled = True
|
||||
|
||||
agent.context_compressor = MagicMock()
|
||||
agent.context_compressor.compress.return_value = [
|
||||
{"role": "user", "content": "Summary of conversation so far."}
|
||||
]
|
||||
agent.context_compressor.context_length = 200
|
||||
# Use a small threshold so the tiny compressed output still
|
||||
# represents >= 85% of it (prevents flag reset).
|
||||
agent.context_compressor.threshold_tokens = 10
|
||||
agent.context_compressor.compression_count = 1
|
||||
agent.context_compressor.last_prompt_tokens = 0
|
||||
|
||||
agent._todo_store = MagicMock()
|
||||
agent._todo_store.format_for_injection.return_value = None
|
||||
agent._build_system_prompt = MagicMock(return_value="system prompt")
|
||||
agent._cached_system_prompt = "old system prompt"
|
||||
agent._session_db = None
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi there"},
|
||||
]
|
||||
agent._compress_context(messages, "system prompt")
|
||||
|
||||
# Post-compression is ~90% of threshold — flag should NOT reset
|
||||
assert agent._context_pressure_warned_at == 0.85
|
||||
|
||||
|
||||
class TestContextPressureGatewayDedup:
|
||||
"""Class-level dedup prevents warning spam across AIAgent instances."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Clear class-level dedup state between tests."""
|
||||
AIAgent._context_pressure_last_warned.clear()
|
||||
|
||||
def test_second_instance_within_cooldown_suppressed(self):
|
||||
"""Same session, same tier, within cooldown — should be suppressed."""
|
||||
import time
|
||||
sid = "test_session_dedup"
|
||||
# Simulate first warning
|
||||
AIAgent._context_pressure_last_warned[sid] = (0.85, time.time())
|
||||
# Second instance checking same tier within cooldown
|
||||
_last = AIAgent._context_pressure_last_warned.get(sid)
|
||||
_should_warn = _last is None or _last[0] < 0.85 or (time.time() - _last[1]) >= AIAgent._CONTEXT_PRESSURE_COOLDOWN
|
||||
assert not _should_warn
|
||||
|
||||
def test_higher_tier_fires_despite_cooldown(self):
|
||||
"""Same session, higher tier — should fire even within cooldown."""
|
||||
import time
|
||||
sid = "test_session_tier"
|
||||
AIAgent._context_pressure_last_warned[sid] = (0.85, time.time())
|
||||
_last = AIAgent._context_pressure_last_warned.get(sid)
|
||||
# 0.95 > 0.85 stored tier → should warn
|
||||
_should_warn = _last is None or _last[0] < 0.95 or (time.time() - _last[1]) >= AIAgent._CONTEXT_PRESSURE_COOLDOWN
|
||||
assert _should_warn
|
||||
|
||||
def test_warning_fires_after_cooldown_expires(self):
|
||||
"""Same session, same tier, after cooldown — should fire again."""
|
||||
import time
|
||||
sid = "test_session_expired"
|
||||
# Set a timestamp far in the past
|
||||
AIAgent._context_pressure_last_warned[sid] = (0.85, time.time() - AIAgent._CONTEXT_PRESSURE_COOLDOWN - 1)
|
||||
_last = AIAgent._context_pressure_last_warned.get(sid)
|
||||
_should_warn = _last is None or _last[0] < 0.85 or (time.time() - _last[1]) >= AIAgent._CONTEXT_PRESSURE_COOLDOWN
|
||||
assert _should_warn
|
||||
|
||||
def test_compression_clears_dedup(self):
|
||||
"""After compression drops below 85%, dedup entry should be cleared."""
|
||||
import time
|
||||
sid = "test_session_clear"
|
||||
AIAgent._context_pressure_last_warned[sid] = (0.85, time.time())
|
||||
assert sid in AIAgent._context_pressure_last_warned
|
||||
# Simulate what _compress_context does on reset
|
||||
AIAgent._context_pressure_last_warned.pop(sid, None)
|
||||
assert sid not in AIAgent._context_pressure_last_warned
|
||||
|
||||
def test_eviction_removes_stale_entries(self):
|
||||
"""Stale entries older than 2x cooldown should be evicted."""
|
||||
import time
|
||||
_now = time.time()
|
||||
AIAgent._context_pressure_last_warned = {
|
||||
"fresh": (0.85, _now),
|
||||
"stale": (0.85, _now - AIAgent._CONTEXT_PRESSURE_COOLDOWN * 3),
|
||||
}
|
||||
_cutoff = _now - AIAgent._CONTEXT_PRESSURE_COOLDOWN * 2
|
||||
AIAgent._context_pressure_last_warned = {
|
||||
k: v for k, v in AIAgent._context_pressure_last_warned.items()
|
||||
if v[1] > _cutoff
|
||||
}
|
||||
assert "fresh" in AIAgent._context_pressure_last_warned
|
||||
assert "stale" not in AIAgent._context_pressure_last_warned
|
||||
@@ -59,7 +59,7 @@ def _make_agent(monkeypatch, api_mode, provider, response_fn):
|
||||
self._disable_streaming = True
|
||||
return super().run_conversation(msg, conversation_history=conversation_history, task_id=task_id)
|
||||
|
||||
return _A(model="test-model", api_key="test-key", provider=provider, api_mode=api_mode)
|
||||
return _A(model="test-model", api_key="test-key", base_url="http://localhost:1234/v1", provider=provider, api_mode=api_mode)
|
||||
|
||||
|
||||
def _anthropic_resp(input_tok, output_tok, cache_read=0, cache_creation=0):
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Guardrail: _create_openai_client must not mutate its input kwargs.
|
||||
|
||||
#10933 injected an httpx.Client directly into the caller's ``client_kwargs``.
|
||||
When the dict was ``self._client_kwargs``, the shared transport was torn down
|
||||
after the first request_complete close and subsequent request-scoped clients
|
||||
wrapped a closed transport, raising ``APIConnectionError('Connection error.')``
|
||||
with cause ``RuntimeError: Cannot send a request, as the client has been closed``
|
||||
on every retry. That PR has since been reverted, but the underlying issue
|
||||
(#10324, connections hanging in CLOSE-WAIT) is still open, so another transport
|
||||
tweak inside this function is likely. This test pins the contract that the
|
||||
function must treat its input dict as read-only.
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from run_agent import AIAgent
|
||||
|
||||
|
||||
@patch("run_agent.OpenAI")
|
||||
def test_create_openai_client_does_not_mutate_input_kwargs(mock_openai):
|
||||
mock_openai.return_value = MagicMock()
|
||||
agent = AIAgent(
|
||||
model="test/model",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
|
||||
kwargs = {"api_key": "test-key", "base_url": "https://api.example.com/v1"}
|
||||
snapshot = dict(kwargs)
|
||||
|
||||
agent._create_openai_client(kwargs, reason="test", shared=False)
|
||||
|
||||
assert kwargs == snapshot, (
|
||||
f"_create_openai_client mutated input kwargs; expected {snapshot}, got {kwargs}"
|
||||
)
|
||||
@@ -4115,8 +4115,8 @@ class TestMemoryNudgeCounterPersistence:
|
||||
"""Counters must exist on the agent after __init__."""
|
||||
with patch("run_agent.get_tool_definitions", return_value=[]):
|
||||
a = AIAgent(
|
||||
model="test", api_key="test-key", provider="openrouter",
|
||||
skip_context_files=True, skip_memory=True,
|
||||
model="test", api_key="test-key", base_url="http://localhost:1234/v1",
|
||||
provider="openrouter", skip_context_files=True, skip_memory=True,
|
||||
)
|
||||
assert hasattr(a, "_turns_since_memory")
|
||||
assert hasattr(a, "_iters_since_skill")
|
||||
|
||||
@@ -279,6 +279,10 @@ raise RuntimeError("deliberate crash")
|
||||
))
|
||||
self.assertEqual(result["status"], "timeout")
|
||||
self.assertIn("timed out", result.get("error", ""))
|
||||
# The timeout message must also appear in output so the LLM always
|
||||
# surfaces it to the user (#10807).
|
||||
self.assertIn("timed out", result.get("output", ""))
|
||||
self.assertIn("\u23f0", result.get("output", ""))
|
||||
|
||||
def test_web_search_tool(self):
|
||||
"""Script calls web_search and processes results."""
|
||||
|
||||
Reference in New Issue
Block a user