From 44adfeaede089c9453ccb68a7b594b6af4dd5caa Mon Sep 17 00:00:00 2001 From: Tosko4 <1294707+Tosko4@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:49:48 +0200 Subject: [PATCH] fix: expose context-engine slash commands --- hermes_cli/commands.py | 9 +- hermes_cli/plugins.py | 18 +- plugins/context_engine/__init__.py | 133 ++++++- run_agent.py | 7 + tests/plugins/test_context_engine_commands.py | 353 ++++++++++++++++++ .../test_plugin_context_engine_init.py | 45 ++- 6 files changed, 553 insertions(+), 12 deletions(-) create mode 100644 tests/plugins/test_context_engine_commands.py diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index b3556d3932df2..0f7868b464518 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -344,7 +344,7 @@ def is_gateway_known_command(name: str | None) -> bool: def should_bypass_active_session(command_name: str | None) -> bool: - """Return True for any resolvable slash command. + """Return True for any gateway-known slash command. Rationale: every gateway-registered slash command either has a specific Level-2 handler in gateway/run.py (/stop, /new, /model, @@ -362,8 +362,10 @@ def should_bypass_active_session(command_name: str | None) -> bool: ACTIVE_SESSION_BYPASS_COMMANDS remains the subset of commands with explicit Level-2 handlers; the rest fall through to the catch-all. + Plugin and context-engine commands are gateway-known too, so they + must bypass the pending user-message queue during active sessions. """ - return resolve_command(command_name) is not None if command_name else False + return is_gateway_known_command(command_name) if command_name else False def _resolve_config_gates() -> set[str]: @@ -432,6 +434,9 @@ def gateway_help_lines() -> list[str]: alias_parts.append(f"`/{a}`") alias_note = f" (alias: {', '.join(alias_parts)})" if alias_parts else "" lines.append(f"`/{cmd.name}{args}` -- {cmd.description}{alias_note}") + for name, description, args_hint in _iter_plugin_command_entries(): + args = f" {args_hint}" if args_hint else "" + lines.append(f"`/{name}{args}` -- {description}") return lines diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 9e9af0e0644d7..e28c5b7617995 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -760,6 +760,8 @@ def discover_and_load(self, force: bool = False) -> None: self._cli_commands.clear() self._plugin_commands.clear() self._plugin_skills.clear() + if hasattr(self, "_context_engine_commands_synced_for"): + delattr(self, "_context_engine_commands_synced_for") self._context_engine = None self._discovered = True @@ -1443,7 +1445,13 @@ def get_plugin_context_engine(): def get_plugin_command_handler(name: str) -> Optional[Callable]: """Return the handler for a plugin-registered slash command, or ``None``.""" - entry = _ensure_plugins_discovered()._plugin_commands.get(name) + manager = _ensure_plugins_discovered() + try: + from plugins.context_engine import sync_configured_context_engine_commands + sync_configured_context_engine_commands() + except Exception: + pass + entry = manager._plugin_commands.get(name) return entry["handler"] if entry else None @@ -1502,7 +1510,13 @@ def get_plugin_commands() -> Dict[str, dict]: Triggers idempotent plugin discovery so callers can use plugin commands before any explicit discover_plugins() call. """ - return _ensure_plugins_discovered()._plugin_commands + manager = _ensure_plugins_discovered() + try: + from plugins.context_engine import sync_configured_context_engine_commands + sync_configured_context_engine_commands() + except Exception: + pass + return manager._plugin_commands def get_plugin_toolsets() -> List[tuple]: diff --git a/plugins/context_engine/__init__.py b/plugins/context_engine/__init__.py index da9206dc349fd..93107dfae329d 100644 --- a/plugins/context_engine/__init__.py +++ b/plugins/context_engine/__init__.py @@ -30,6 +30,46 @@ _CONTEXT_ENGINE_PLUGINS_DIR = Path(__file__).parent +def _selected_context_engine_name() -> str: + """Return the configured context engine name.""" + try: + from hermes_cli.config import cfg_get, load_config + config = load_config() + return cfg_get(config, "context", "engine", default="compressor") or "compressor" + except Exception: + return "compressor" + + +def mark_context_engine_commands_synced(engine_name: str) -> None: + try: + from hermes_cli.plugins import get_plugin_manager + setattr(get_plugin_manager(), "_context_engine_commands_synced_for", engine_name) + except Exception: + pass + + +def sync_configured_context_engine_commands() -> None: + """Ensure slash commands for the configured context engine are registered. + + Gateway help/menu/dispatch can run before the first AIAgent instance is + created. In that path no one has loaded the configured context engine yet, + so command enumeration must explicitly preload its command surface. + """ + engine_name = _selected_context_engine_name() + try: + from hermes_cli.plugins import get_plugin_manager + manager = get_plugin_manager() + except Exception: + manager = None + if manager is not None and getattr(manager, "_context_engine_commands_synced_for", None) == engine_name: + return + if engine_name == "compressor": + clear_context_engine_commands() + mark_context_engine_commands_synced(engine_name) + return + load_context_engine(engine_name) + + def discover_context_engines() -> List[Tuple[str, str, bool]]: """Scan plugins/context_engine/ for available engines. @@ -63,7 +103,7 @@ def discover_context_engines() -> List[Tuple[str, str, bool]]: # Quick availability check — try loading and calling is_available() available = True try: - engine = _load_engine_from_dir(child) + engine = _load_engine_from_dir(child, register_commands=False) if engine is None: available = False elif hasattr(engine, "is_available"): @@ -82,22 +122,43 @@ def load_context_engine(name: str) -> Optional["ContextEngine"]: Returns None if the engine is not found or fails to load. """ engine_dir = _CONTEXT_ENGINE_PLUGINS_DIR / name + clear_context_engine_commands() if not engine_dir.is_dir(): logger.debug("Context engine '%s' not found in %s", name, _CONTEXT_ENGINE_PLUGINS_DIR) + mark_context_engine_commands_synced(name) return None try: - engine = _load_engine_from_dir(engine_dir) + engine = _load_engine_from_dir(engine_dir, register_commands=True) if engine: + mark_context_engine_commands_synced(name) return engine logger.warning("Context engine '%s' loaded but no engine instance found", name) + mark_context_engine_commands_synced(name) return None except Exception as e: logger.warning("Failed to load context engine '%s': %s", name, e) + mark_context_engine_commands_synced(name) return None -def _load_engine_from_dir(engine_dir: Path) -> Optional["ContextEngine"]: +def clear_context_engine_commands() -> None: + """Remove slash commands registered by a previously loaded context engine.""" + try: + from hermes_cli.plugins import get_plugin_manager + except Exception: + return + try: + commands = get_plugin_manager()._plugin_commands + except Exception: + return + for command_name, meta in list(commands.items()): + plugin_name = meta.get("plugin") if isinstance(meta, dict) else None + if isinstance(plugin_name, str) and plugin_name.startswith("context_engine:"): + commands.pop(command_name, None) + + +def _load_engine_from_dir(engine_dir: Path, *, register_commands: bool = False) -> Optional["ContextEngine"]: """Import an engine module and extract the ContextEngine instance. The module must have either: @@ -174,12 +235,15 @@ def _load_engine_from_dir(engine_dir: Path) -> Optional["ContextEngine"]: # Try register(ctx) pattern first (how plugins are written) if hasattr(mod, "register"): - collector = _EngineCollector() + collector = _EngineCollector(name, register_commands=register_commands) try: mod.register(collector) if collector.engine: + collector.commit_commands() return collector.engine except Exception as e: + if register_commands: + clear_context_engine_commands() logger.debug("register() failed for %s: %s", name, e) # Fallback: find a ContextEngine subclass and instantiate it @@ -197,14 +261,71 @@ def _load_engine_from_dir(engine_dir: Path) -> Optional["ContextEngine"]: class _EngineCollector: - """Fake plugin context that captures register_context_engine calls.""" + """Plugin-like context that captures context-engine registrations.""" - def __init__(self): + def __init__(self, engine_name: str, *, register_commands: bool = False): + self.engine_name = engine_name + self.register_commands = register_commands self.engine = None + self._pending_commands = [] def register_context_engine(self, engine): self.engine = engine + def register_command(self, name, handler, description="", args_hint=""): + """Register an in-session slash command for the active context engine.""" + if not self.register_commands: + return + clean = str(name).lower().strip().lstrip("/").replace(" ", "-") + if not clean: + logger.warning( + "Context engine '%s' tried to register a command with an empty name.", + self.engine_name, + ) + return + try: + from hermes_cli.commands import resolve_command + if resolve_command(clean) is not None: + logger.warning( + "Context engine '%s' tried to register command '/%s' which conflicts " + "with a built-in command. Skipping.", + self.engine_name, clean, + ) + return + except Exception: + pass + self._pending_commands.append((clean, handler, description, str(args_hint or "").strip())) + + def commit_commands(self): + """Commit buffered slash commands after the engine registered successfully.""" + if not self.register_commands or not self._pending_commands: + return + try: + from hermes_cli.plugins import get_plugin_manager + commands = get_plugin_manager()._plugin_commands + except Exception as exc: + logger.debug( + "Context engine '%s' failed to register commands: %s", + self.engine_name, exc, + ) + return + for clean, handler, description, args_hint in self._pending_commands: + existing = commands.get(clean) + existing_plugin = existing.get("plugin") if isinstance(existing, dict) else None + if existing is not None and existing_plugin != f"context_engine:{self.engine_name}": + logger.warning( + "Context engine '%s' tried to register command '/%s' which is " + "already registered by plugin '%s'. Skipping.", + self.engine_name, clean, existing_plugin or "unknown", + ) + continue + commands[clean] = { + "handler": handler, + "description": description or "Plugin command", + "plugin": f"context_engine:{self.engine_name}", + "args_hint": args_hint, + } + # No-op for other registration methods def register_tool(self, *args, **kwargs): pass diff --git a/run_agent.py b/run_agent.py index b60f6c43ce693..b4fdf04bf3b51 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2302,6 +2302,13 @@ def __init__( _engine_name, ) # else: config says "compressor" — use built-in, don't auto-activate plugins + else: + try: + from plugins.context_engine import clear_context_engine_commands, mark_context_engine_commands_synced + clear_context_engine_commands() + mark_context_engine_commands_synced("compressor") + except Exception: + pass if _selected_engine is not None: self.context_compressor = _selected_engine diff --git a/tests/plugins/test_context_engine_commands.py b/tests/plugins/test_context_engine_commands.py new file mode 100644 index 0000000000000..b586b49f1cd46 --- /dev/null +++ b/tests/plugins/test_context_engine_commands.py @@ -0,0 +1,353 @@ +"""Tests for slash commands registered by repo-shipped context engines.""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path +from unittest.mock import patch + +from hermes_cli.plugins import PluginManager, get_plugin_command_handler + + +def _write_context_engine(root: Path, name: str, register_line: str) -> Path: + sys.modules.pop(f"plugins.context_engine.{name}", None) + engine_dir = root / name + engine_dir.mkdir(parents=True, exist_ok=True) + (engine_dir / "__init__.py").write_text(_context_engine_source(name, register_line)) + return engine_dir + + +def _context_engine_source(name: str, register_body: str) -> str: + return ( + "from agent.context_engine import ContextEngine\n\n" + "class StubEngine(ContextEngine):\n" + " @property\n" + " def name(self):\n" + f" return {name!r}\n\n" + " def update_from_response(self, usage):\n" + " return None\n\n" + " def should_compress(self, prompt_tokens=None):\n" + " return False\n\n" + " def compress(self, messages, current_tokens=None, focus_topic=None):\n" + " return messages\n\n" + "def register(ctx):\n" + " ctx.register_context_engine(StubEngine())\n" + f" {register_body}\n" + ) + + +def _write_raw_context_engine(root: Path, name: str, source: str) -> Path: + sys.modules.pop(f"plugins.context_engine.{name}", None) + engine_dir = root / name + engine_dir.mkdir(parents=True, exist_ok=True) + (engine_dir / "__init__.py").write_text(source) + return engine_dir + + +def _configure_context_engine(tmp_path, monkeypatch, name: str) -> None: + hermes_home = tmp_path / "home" + hermes_home.mkdir(exist_ok=True) + (hermes_home / "config.yaml").write_text(f"context:\n engine: {name}\n") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + +def test_repo_context_engine_loader_registers_slash_command(tmp_path, monkeypatch): + """Repo-shipped context engines should expose registered slash commands.""" + import hermes_cli.plugins as plugins_mod + import plugins.context_engine as context_engine_mod + + engine_root = tmp_path / "context_engine" + _write_context_engine( + engine_root, + "slash_engine", + 'ctx.register_command("lcm", lambda raw_args: f"lcm:{raw_args}", description="LCM diagnostics")', + ) + _configure_context_engine(tmp_path, monkeypatch, "slash_engine") + monkeypatch.setattr(context_engine_mod, "_CONTEXT_ENGINE_PLUGINS_DIR", engine_root) + manager = PluginManager() + manager._discovered = True + + with patch.object(plugins_mod, "_plugin_manager", manager): + engine = context_engine_mod.load_context_engine("slash_engine") + assert engine is not None + assert engine.name == "slash_engine" + + handler = get_plugin_command_handler("lcm") + assert handler is not None + assert handler("status") == "lcm:status" + assert manager._plugin_commands["lcm"]["description"] == "LCM diagnostics" + assert manager._plugin_commands["lcm"]["plugin"] == "context_engine:slash_engine" + + from hermes_cli.commands import gateway_help_lines, should_bypass_active_session, telegram_bot_commands + + assert ("lcm", "LCM diagnostics") in telegram_bot_commands() + assert "`/lcm` -- LCM diagnostics" in gateway_help_lines() + assert should_bypass_active_session("lcm") is True + + +def test_repo_context_engine_command_conflicts_with_builtins_are_rejected(tmp_path, monkeypatch, caplog): + """Repo-shipped context-engine commands must keep built-in conflict protection.""" + import hermes_cli.plugins as plugins_mod + import plugins.context_engine as context_engine_mod + + engine_root = tmp_path / "context_engine" + _write_context_engine( + engine_root, + "conflict_engine", + 'ctx.register_command("help", lambda raw_args: "bad", description="Conflicting command")', + ) + monkeypatch.setattr(context_engine_mod, "_CONTEXT_ENGINE_PLUGINS_DIR", engine_root) + manager = PluginManager() + manager._discovered = True + + with patch.object(plugins_mod, "_plugin_manager", manager): + with caplog.at_level(logging.WARNING, logger="plugins.context_engine"): + engine = context_engine_mod.load_context_engine("conflict_engine") + + assert engine is not None + assert "help" not in manager._plugin_commands + assert "conflicts with a built-in command" in caplog.text + + +def test_loading_different_context_engine_clears_stale_context_engine_commands(tmp_path, monkeypatch): + """Switching active context engines should not leave stale slash commands behind.""" + import hermes_cli.plugins as plugins_mod + import plugins.context_engine as context_engine_mod + + engine_root = tmp_path / "context_engine" + _write_context_engine( + engine_root, + "slash_engine", + 'ctx.register_command("lcm", lambda raw_args: "old", description="Old")', + ) + _write_context_engine( + engine_root, + "plain_engine", + "pass", + ) + monkeypatch.setattr(context_engine_mod, "_CONTEXT_ENGINE_PLUGINS_DIR", engine_root) + manager = PluginManager() + manager._discovered = True + + with patch.object(plugins_mod, "_plugin_manager", manager): + assert context_engine_mod.load_context_engine("slash_engine") is not None + assert "lcm" in manager._plugin_commands + + assert context_engine_mod.load_context_engine("plain_engine") is not None + assert "lcm" not in manager._plugin_commands + + +def test_missing_context_engine_clears_stale_context_engine_commands(tmp_path, monkeypatch): + """Failed context-engine lookups should not leave stale slash commands dispatchable.""" + import hermes_cli.plugins as plugins_mod + import plugins.context_engine as context_engine_mod + + engine_root = tmp_path / "context_engine" + _write_context_engine( + engine_root, + "slash_engine", + 'ctx.register_command("lcm", lambda raw_args: "old", description="Old")', + ) + monkeypatch.setattr(context_engine_mod, "_CONTEXT_ENGINE_PLUGINS_DIR", engine_root) + manager = PluginManager() + manager._discovered = True + + with patch.object(plugins_mod, "_plugin_manager", manager): + assert context_engine_mod.load_context_engine("slash_engine") is not None + assert "lcm" in manager._plugin_commands + + assert context_engine_mod.load_context_engine("missing_engine") is None + assert "lcm" not in manager._plugin_commands + + +def test_failed_context_engine_register_does_not_commit_pending_commands(tmp_path, monkeypatch): + """Commands buffered before a register() crash should not become dispatchable.""" + import hermes_cli.plugins as plugins_mod + import plugins.context_engine as context_engine_mod + + engine_root = tmp_path / "context_engine" + _write_raw_context_engine( + engine_root, + "crashy_engine", + _context_engine_source( + "crashy_engine", + 'ctx.register_command("lcm", lambda raw_args: "bad", description="Bad")\n raise RuntimeError("boom")', + ), + ) + monkeypatch.setattr(context_engine_mod, "_CONTEXT_ENGINE_PLUGINS_DIR", engine_root) + manager = PluginManager() + manager._discovered = True + + with patch.object(plugins_mod, "_plugin_manager", manager): + engine = context_engine_mod.load_context_engine("crashy_engine") + + assert engine is not None + assert engine.name == "crashy_engine" + assert "lcm" not in manager._plugin_commands + + +def test_context_engine_register_without_engine_does_not_commit_pending_commands(tmp_path, monkeypatch): + """A register() hook must register an engine before its slash commands become active.""" + import hermes_cli.plugins as plugins_mod + import plugins.context_engine as context_engine_mod + + engine_root = tmp_path / "context_engine" + _write_raw_context_engine( + engine_root, + "no_engine", + "def register(ctx):\n" + " ctx.register_command('lcm', lambda raw_args: 'bad', description='Bad')\n", + ) + monkeypatch.setattr(context_engine_mod, "_CONTEXT_ENGINE_PLUGINS_DIR", engine_root) + manager = PluginManager() + manager._discovered = True + + with patch.object(plugins_mod, "_plugin_manager", manager): + assert context_engine_mod.load_context_engine("no_engine") is None + assert "lcm" not in manager._plugin_commands + + +def test_context_engine_command_does_not_overwrite_normal_plugin_command(tmp_path, monkeypatch, caplog): + """Context-engine commands must not silently replace normal plugin commands.""" + import hermes_cli.plugins as plugins_mod + import plugins.context_engine as context_engine_mod + + engine_root = tmp_path / "context_engine" + _write_context_engine( + engine_root, + "slash_engine", + 'ctx.register_command("lcm", lambda raw_args: "context", description="Context")', + ) + monkeypatch.setattr(context_engine_mod, "_CONTEXT_ENGINE_PLUGINS_DIR", engine_root) + manager = PluginManager() + manager._discovered = True + normal_handler = lambda raw_args: "normal" + manager._plugin_commands["lcm"] = { + "handler": normal_handler, + "description": "Normal plugin command", + "plugin": "normal-plugin", + "args_hint": "", + } + + with patch.object(plugins_mod, "_plugin_manager", manager): + with caplog.at_level(logging.WARNING, logger="plugins.context_engine"): + assert context_engine_mod.load_context_engine("slash_engine") is not None + + assert manager._plugin_commands["lcm"]["handler"] is normal_handler + assert manager._plugin_commands["lcm"]["plugin"] == "normal-plugin" + assert "already registered by plugin 'normal-plugin'" in caplog.text + + +def test_discovery_failure_does_not_clear_active_context_engine_commands(tmp_path, monkeypatch): + """Availability discovery must stay side-effect-free even if a candidate crashes.""" + import hermes_cli.plugins as plugins_mod + import plugins.context_engine as context_engine_mod + + engine_root = tmp_path / "context_engine" + _write_raw_context_engine( + engine_root, + "crashy_discovery", + _context_engine_source( + "crashy_discovery", + 'ctx.register_command("other", lambda raw_args: "bad", description="Bad")\n raise RuntimeError("boom")', + ), + ) + monkeypatch.setattr(context_engine_mod, "_CONTEXT_ENGINE_PLUGINS_DIR", engine_root) + manager = PluginManager() + manager._discovered = True + active_handler = lambda raw_args: "active" + manager._plugin_commands["lcm"] = { + "handler": active_handler, + "description": "Active context engine command", + "plugin": "context_engine:lcm", + "args_hint": "", + } + + with patch.object(plugins_mod, "_plugin_manager", manager): + discovered = context_engine_mod.discover_context_engines() + + assert discovered == [("crashy_discovery", "", True)] + assert manager._plugin_commands["lcm"]["handler"] is active_handler + assert "other" not in manager._plugin_commands + + +def test_fresh_gateway_surfaces_configured_context_engine_command_before_agent_init(tmp_path, monkeypatch): + """Gateway command surfaces should preload commands for configured context engines.""" + import hermes_cli.plugins as plugins_mod + import plugins.context_engine as context_engine_mod + from hermes_cli.commands import gateway_help_lines, telegram_bot_commands + + hermes_home = tmp_path / "home" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text("context:\n engine: slash_engine\n") + engine_root = tmp_path / "context_engine" + _write_context_engine( + engine_root, + "slash_engine", + 'ctx.register_command("lcm", lambda raw_args: f"fresh:{raw_args}", description="Fresh LCM")', + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(context_engine_mod, "_CONTEXT_ENGINE_PLUGINS_DIR", engine_root) + manager = PluginManager() + + with patch.object(plugins_mod, "_plugin_manager", manager): + assert "lcm" not in manager._plugin_commands + + assert ("lcm", "Fresh LCM") in telegram_bot_commands() + assert "`/lcm` -- Fresh LCM" in gateway_help_lines() + handler = get_plugin_command_handler("lcm") + assert handler is not None + assert handler("status") == "fresh:status" + + +def test_forced_plugin_refresh_resyncs_configured_context_engine_command(tmp_path, monkeypatch): + """Forced plugin discovery should not leave the context-engine command sync marker stale.""" + import hermes_cli.plugins as plugins_mod + import plugins.context_engine as context_engine_mod + + _configure_context_engine(tmp_path, monkeypatch, "slash_engine") + engine_root = tmp_path / "context_engine" + _write_context_engine( + engine_root, + "slash_engine", + 'ctx.register_command("lcm", lambda raw_args: f"fresh:{raw_args}", description="Fresh LCM")', + ) + monkeypatch.setattr(context_engine_mod, "_CONTEXT_ENGINE_PLUGINS_DIR", engine_root) + manager = PluginManager() + + with patch.object(plugins_mod, "_plugin_manager", manager): + handler = get_plugin_command_handler("lcm") + assert handler is not None + assert handler("status") == "fresh:status" + assert getattr(manager, "_context_engine_commands_synced_for") == "slash_engine" + + manager.discover_and_load(force=True) + assert "lcm" not in manager._plugin_commands + assert not hasattr(manager, "_context_engine_commands_synced_for") + + handler = get_plugin_command_handler("lcm") + assert handler is not None + assert handler("again") == "fresh:again" + + +def test_context_engine_discovery_does_not_surface_inactive_slash_commands(tmp_path, monkeypatch): + """Availability discovery should not register commands for inactive engines.""" + import hermes_cli.plugins as plugins_mod + import plugins.context_engine as context_engine_mod + + engine_root = tmp_path / "context_engine" + _write_context_engine( + engine_root, + "inactive_engine", + 'ctx.register_command("inactive", lambda raw_args: "bad", description="Inactive")', + ) + monkeypatch.setattr(context_engine_mod, "_CONTEXT_ENGINE_PLUGINS_DIR", engine_root) + manager = PluginManager() + manager._discovered = True + + with patch.object(plugins_mod, "_plugin_manager", manager): + discovered = context_engine_mod.discover_context_engines() + + assert discovered == [("inactive_engine", "", True)] + assert "inactive" not in manager._plugin_commands diff --git a/tests/run_agent/test_plugin_context_engine_init.py b/tests/run_agent/test_plugin_context_engine_init.py index 60e89889088ef..3f3f46b7054d7 100644 --- a/tests/run_agent/test_plugin_context_engine_init.py +++ b/tests/run_agent/test_plugin_context_engine_init.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch from agent.context_engine import ContextEngine +from hermes_cli.plugins import PluginManager class _StubEngine(ContextEngine): @@ -44,7 +45,7 @@ def test_plugin_engine_gets_context_length_on_init(): from run_agent import AIAgent agent = AIAgent( - api_key="test-key-1234567890", + api_key="test", base_url="https://openrouter.ai/api/v1", quiet_mode=True, skip_context_files=True, @@ -75,7 +76,7 @@ def test_plugin_engine_update_model_args(): agent = AIAgent( model="openrouter/auto", - api_key="test-key-1234567890", + api_key="test", base_url="https://openrouter.ai/api/v1", quiet_mode=True, skip_context_files=True, @@ -89,3 +90,43 @@ def test_plugin_engine_update_model_args(): assert "provider" in kw # Should NOT pass api_mode — the ABC doesn't accept it assert "api_mode" not in kw + + +def test_compressor_selection_clears_stale_context_engine_commands(): + """Switching back to the built-in compressor should remove context-engine commands.""" + cfg = {"context": {"engine": "compressor"}, "agent": {}} + manager = PluginManager() + manager._discovered = True + manager._plugin_commands["lcm"] = { + "handler": lambda raw_args: "stale", + "description": "Stale LCM", + "plugin": "context_engine:lcm", + "args_hint": "", + } + manager._plugin_commands["normal"] = { + "handler": lambda raw_args: "keep", + "description": "Normal plugin command", + "plugin": "normal-plugin", + "args_hint": "", + } + + with ( + patch("hermes_cli.config.load_config", return_value=cfg), + patch("hermes_cli.plugins._plugin_manager", manager), + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + from run_agent import AIAgent + + agent = AIAgent( + api_key="test", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + assert agent.context_compressor.name == "compressor" + assert "lcm" not in manager._plugin_commands + assert "normal" in manager._plugin_commands