Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor

This commit is contained in:
Brooklyn Nicholson
2026-04-16 08:23:20 -05:00
128 changed files with 8357 additions and 842 deletions

View File

@@ -613,6 +613,7 @@ class TestDetectVenvDir:
# Not inside a virtualenv
monkeypatch.setattr("sys.prefix", "/usr")
monkeypatch.setattr("sys.base_prefix", "/usr")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path)
dot_venv = tmp_path / ".venv"
@@ -624,6 +625,7 @@ class TestDetectVenvDir:
def test_falls_back_to_venv_directory(self, tmp_path, monkeypatch):
monkeypatch.setattr("sys.prefix", "/usr")
monkeypatch.setattr("sys.base_prefix", "/usr")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path)
venv = tmp_path / "venv"
@@ -635,6 +637,7 @@ class TestDetectVenvDir:
def test_prefers_dot_venv_over_venv(self, tmp_path, monkeypatch):
monkeypatch.setattr("sys.prefix", "/usr")
monkeypatch.setattr("sys.base_prefix", "/usr")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path)
(tmp_path / ".venv").mkdir()
@@ -646,6 +649,7 @@ class TestDetectVenvDir:
def test_returns_none_when_no_virtualenv(self, tmp_path, monkeypatch):
monkeypatch.setattr("sys.prefix", "/usr")
monkeypatch.setattr("sys.base_prefix", "/usr")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path)
result = gateway_cli._detect_venv_dir()

View File

@@ -0,0 +1,101 @@
"""Regression tests for Copilot api_mode recomputation during /model switch.
When switching models within the Copilot provider (e.g. GPT-5 → Claude),
the stale api_mode from resolve_runtime_provider must be overridden with
a fresh value computed from the *new* model. Without the fix, Claude
requests went through the Responses API and failed with
``unsupported_api_for_model``.
"""
from unittest.mock import patch
from hermes_cli.model_switch import switch_model
_MOCK_VALIDATION = {
"accepted": True,
"persist": True,
"recognized": True,
"message": None,
}
def _run_copilot_switch(
raw_input: str,
current_provider: str = "copilot",
current_model: str = "gpt-5.4",
explicit_provider: str = "",
runtime_api_mode: str = "codex_responses",
):
"""Run switch_model with Copilot mocks and return the result."""
with (
patch("hermes_cli.model_switch.resolve_alias", return_value=None),
patch("hermes_cli.model_switch.list_provider_models", return_value=[]),
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
"api_key": "ghu_test_token",
"base_url": "https://api.githubcopilot.com",
"api_mode": runtime_api_mode,
},
),
patch(
"hermes_cli.models.validate_requested_model",
return_value=_MOCK_VALIDATION,
),
patch("hermes_cli.model_switch.get_model_info", return_value=None),
patch("hermes_cli.model_switch.get_model_capabilities", return_value=None),
patch("hermes_cli.models.detect_provider_for_model", return_value=None),
):
return switch_model(
raw_input=raw_input,
current_provider=current_provider,
current_model=current_model,
explicit_provider=explicit_provider,
)
def test_same_provider_copilot_switch_recomputes_api_mode():
"""GPT-5 → Claude on copilot: api_mode must flip to chat_completions."""
result = _run_copilot_switch(
raw_input="claude-opus-4.6",
current_provider="copilot",
current_model="gpt-5.4",
)
assert result.success, f"switch_model failed: {result.error_message}"
assert result.new_model == "claude-opus-4.6"
assert result.target_provider == "copilot"
assert result.api_mode == "chat_completions"
def test_explicit_copilot_switch_uses_selected_model_api_mode():
"""Cross-provider switch to copilot: api_mode from new model, not stale runtime."""
result = _run_copilot_switch(
raw_input="claude-opus-4.6",
current_provider="openrouter",
current_model="anthropic/claude-sonnet-4.6",
explicit_provider="copilot",
)
assert result.success, f"switch_model failed: {result.error_message}"
assert result.new_model == "claude-opus-4.6"
assert result.target_provider == "github-copilot"
assert result.api_mode == "chat_completions"
def test_copilot_gpt5_keeps_codex_responses():
"""GPT-5 → GPT-5 on copilot: api_mode must stay codex_responses."""
result = _run_copilot_switch(
raw_input="gpt-5.4-mini",
current_provider="copilot",
current_model="gpt-5.4",
runtime_api_mode="codex_responses",
)
assert result.success, f"switch_model failed: {result.error_message}"
assert result.new_model == "gpt-5.4-mini"
assert result.target_provider == "copilot"
# gpt-5.4-mini is a GPT-5 variant — should use codex_responses
# (gpt-5-mini is the special case that uses chat_completions)
assert result.api_mode == "codex_responses"

View File

@@ -163,7 +163,7 @@ class TestNormalizeProvider:
class TestProviderLabel:
def test_known_labels_and_auto(self):
assert provider_label("anthropic") == "Anthropic"
assert provider_label("kimi") == "Kimi / Moonshot"
assert provider_label("kimi") == "Kimi / Kimi Coding Plan"
assert provider_label("copilot") == "GitHub Copilot"
assert provider_label("copilot-acp") == "GitHub Copilot ACP"
assert provider_label("auto") == "Auto"

View File

@@ -0,0 +1,351 @@
"""Tests for Ollama Cloud provider integration."""
import os
import pytest
from unittest.mock import patch, MagicMock
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider, resolve_api_key_provider_credentials
from hermes_cli.models import _PROVIDER_MODELS, _PROVIDER_LABELS, _PROVIDER_ALIASES, normalize_provider
from hermes_cli.model_normalize import normalize_model_for_provider
from agent.model_metadata import _URL_TO_PROVIDER, _PROVIDER_PREFIXES
from agent.models_dev import PROVIDER_TO_MODELS_DEV, list_agentic_models
# ── Provider Registry ──
class TestOllamaCloudProviderRegistry:
def test_ollama_cloud_in_registry(self):
assert "ollama-cloud" in PROVIDER_REGISTRY
def test_ollama_cloud_config(self):
pconfig = PROVIDER_REGISTRY["ollama-cloud"]
assert pconfig.id == "ollama-cloud"
assert pconfig.name == "Ollama Cloud"
assert pconfig.auth_type == "api_key"
assert pconfig.inference_base_url == "https://ollama.com/v1"
def test_ollama_cloud_env_vars(self):
pconfig = PROVIDER_REGISTRY["ollama-cloud"]
assert pconfig.api_key_env_vars == ("OLLAMA_API_KEY",)
assert pconfig.base_url_env_var == "OLLAMA_BASE_URL"
def test_ollama_cloud_base_url(self):
assert "ollama.com" in PROVIDER_REGISTRY["ollama-cloud"].inference_base_url
# ── Provider Aliases ──
PROVIDER_ENV_VARS = (
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"GOOGLE_API_KEY", "GEMINI_API_KEY", "OLLAMA_API_KEY",
"GLM_API_KEY", "ZAI_API_KEY", "KIMI_API_KEY",
"MINIMAX_API_KEY", "DEEPSEEK_API_KEY",
)
@pytest.fixture(autouse=True)
def _clean_provider_env(monkeypatch):
for var in PROVIDER_ENV_VARS:
monkeypatch.delenv(var, raising=False)
class TestOllamaCloudAliases:
def test_explicit_ollama_cloud(self):
assert resolve_provider("ollama-cloud") == "ollama-cloud"
def test_alias_ollama_underscore(self):
"""ollama_cloud (underscore) is the unambiguous cloud alias."""
assert resolve_provider("ollama_cloud") == "ollama-cloud"
def test_bare_ollama_stays_local(self):
"""Bare 'ollama' alias routes to 'custom' (local) — not cloud."""
assert resolve_provider("ollama") == "custom"
def test_models_py_aliases(self):
assert _PROVIDER_ALIASES.get("ollama_cloud") == "ollama-cloud"
# bare "ollama" stays local
assert _PROVIDER_ALIASES.get("ollama") == "custom"
def test_normalize_provider(self):
assert normalize_provider("ollama-cloud") == "ollama-cloud"
# ── Auto-detection ──
class TestOllamaCloudAutoDetection:
def test_auto_detects_ollama_api_key(self, monkeypatch):
monkeypatch.setenv("OLLAMA_API_KEY", "test-ollama-key")
assert resolve_provider("auto") == "ollama-cloud"
# ── Credential Resolution ──
class TestOllamaCloudCredentials:
def test_resolve_with_ollama_api_key(self, monkeypatch):
monkeypatch.setenv("OLLAMA_API_KEY", "ollama-secret")
creds = resolve_api_key_provider_credentials("ollama-cloud")
assert creds["provider"] == "ollama-cloud"
assert creds["api_key"] == "ollama-secret"
assert creds["base_url"] == "https://ollama.com/v1"
def test_resolve_with_custom_base_url(self, monkeypatch):
monkeypatch.setenv("OLLAMA_API_KEY", "key")
monkeypatch.setenv("OLLAMA_BASE_URL", "https://custom.ollama/v1")
creds = resolve_api_key_provider_credentials("ollama-cloud")
assert creds["base_url"] == "https://custom.ollama/v1"
def test_runtime_ollama_cloud(self, monkeypatch):
monkeypatch.setenv("OLLAMA_API_KEY", "ollama-key")
from hermes_cli.runtime_provider import resolve_runtime_provider
result = resolve_runtime_provider(requested="ollama-cloud")
assert result["provider"] == "ollama-cloud"
assert result["api_mode"] == "chat_completions"
assert result["api_key"] == "ollama-key"
assert result["base_url"] == "https://ollama.com/v1"
# ── Model Catalog (dynamic — no static list) ──
class TestOllamaCloudModelCatalog:
def test_no_static_model_list(self):
"""Ollama Cloud models are fetched dynamically — no static list to maintain."""
assert "ollama-cloud" not in _PROVIDER_MODELS
def test_provider_label(self):
assert "ollama-cloud" in _PROVIDER_LABELS
assert _PROVIDER_LABELS["ollama-cloud"] == "Ollama Cloud"
# ── Merged Model Discovery ──
class TestOllamaCloudMergedDiscovery:
def test_merges_live_and_models_dev(self, tmp_path, monkeypatch):
"""Live API models appear first, models.dev additions fill gaps."""
from hermes_cli.models import fetch_ollama_cloud_models
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("OLLAMA_API_KEY", "test-key")
mock_mdev = {
"ollama-cloud": {
"models": {
"glm-5": {"tool_call": True},
"kimi-k2.5": {"tool_call": True},
"nemotron-3-super": {"tool_call": True},
}
}
}
with patch("hermes_cli.models.fetch_api_models", return_value=["qwen3.5:397b", "glm-5"]), \
patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev):
result = fetch_ollama_cloud_models(force_refresh=True)
# Live models first, then models.dev additions (deduped)
assert result[0] == "qwen3.5:397b" # from live API
assert result[1] == "glm-5" # from live API (also in models.dev)
assert "kimi-k2.5" in result # from models.dev only
assert "nemotron-3-super" in result # from models.dev only
assert result.count("glm-5") == 1 # no duplicates
def test_falls_back_to_models_dev_without_api_key(self, tmp_path, monkeypatch):
"""Without API key, only models.dev results are returned."""
from hermes_cli.models import fetch_ollama_cloud_models
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
mock_mdev = {
"ollama-cloud": {
"models": {
"glm-5": {"tool_call": True},
}
}
}
with patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev):
result = fetch_ollama_cloud_models(force_refresh=True)
assert result == ["glm-5"]
def test_uses_disk_cache(self, tmp_path, monkeypatch):
"""Second call returns cached results without hitting APIs."""
from hermes_cli.models import fetch_ollama_cloud_models
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("OLLAMA_API_KEY", "test-key")
with patch("hermes_cli.models.fetch_api_models", return_value=["model-a"]) as mock_api, \
patch("agent.models_dev.fetch_models_dev", return_value={}):
first = fetch_ollama_cloud_models(force_refresh=True)
assert first == ["model-a"]
assert mock_api.call_count == 1
# Second call — should use disk cache, not call API
second = fetch_ollama_cloud_models()
assert second == ["model-a"]
assert mock_api.call_count == 1 # no extra API call
def test_force_refresh_bypasses_cache(self, tmp_path, monkeypatch):
"""force_refresh=True always hits the API even with fresh cache."""
from hermes_cli.models import fetch_ollama_cloud_models
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("OLLAMA_API_KEY", "test-key")
with patch("hermes_cli.models.fetch_api_models", return_value=["model-a"]) as mock_api, \
patch("agent.models_dev.fetch_models_dev", return_value={}):
fetch_ollama_cloud_models(force_refresh=True)
fetch_ollama_cloud_models(force_refresh=True)
assert mock_api.call_count == 2
def test_stale_cache_used_on_total_failure(self, tmp_path, monkeypatch):
"""If both API and models.dev fail, stale cache is returned."""
from hermes_cli.models import fetch_ollama_cloud_models, _save_ollama_cloud_cache
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("OLLAMA_API_KEY", "test-key")
# Pre-populate a stale cache
_save_ollama_cloud_cache(["stale-model"])
# Make the cache appear stale by backdating it
import json
cache_path = tmp_path / "ollama_cloud_models_cache.json"
with open(cache_path) as f:
data = json.load(f)
data["cached_at"] = 0 # epoch = very stale
with open(cache_path, "w") as f:
json.dump(data, f)
with patch("hermes_cli.models.fetch_api_models", return_value=None), \
patch("agent.models_dev.fetch_models_dev", return_value={}):
result = fetch_ollama_cloud_models(force_refresh=True)
assert result == ["stale-model"]
def test_empty_on_total_failure_no_cache(self, tmp_path, monkeypatch):
"""Returns empty list when everything fails and no cache exists."""
from hermes_cli.models import fetch_ollama_cloud_models
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
with patch("agent.models_dev.fetch_models_dev", return_value={}):
result = fetch_ollama_cloud_models(force_refresh=True)
assert result == []
# ── Model Normalization ──
class TestOllamaCloudModelNormalization:
def test_passthrough_bare_name(self):
"""Ollama Cloud is a passthrough provider — model names used as-is."""
assert normalize_model_for_provider("qwen3.5:397b", "ollama-cloud") == "qwen3.5:397b"
def test_passthrough_with_tag(self):
assert normalize_model_for_provider("cogito-2.1:671b", "ollama-cloud") == "cogito-2.1:671b"
def test_passthrough_no_tag(self):
assert normalize_model_for_provider("glm-5", "ollama-cloud") == "glm-5"
# ── URL-to-Provider Mapping ──
class TestOllamaCloudUrlMapping:
def test_url_to_provider(self):
assert _URL_TO_PROVIDER.get("ollama.com") == "ollama-cloud"
def test_provider_prefix_canonical(self):
assert "ollama-cloud" in _PROVIDER_PREFIXES
def test_provider_prefix_alias(self):
assert "ollama" in _PROVIDER_PREFIXES
# ── models.dev Integration ──
class TestOllamaCloudModelsDev:
def test_ollama_cloud_mapped(self):
assert PROVIDER_TO_MODELS_DEV.get("ollama-cloud") == "ollama-cloud"
def test_list_agentic_models_with_mock_data(self):
"""list_agentic_models filters correctly from mock models.dev data."""
mock_data = {
"ollama-cloud": {
"models": {
"qwen3.5:397b": {"tool_call": True},
"glm-5": {"tool_call": True},
"nemotron-3-nano:30b": {"tool_call": True},
"some-embedding:latest": {"tool_call": False},
}
}
}
with patch("agent.models_dev.fetch_models_dev", return_value=mock_data):
result = list_agentic_models("ollama-cloud")
assert "qwen3.5:397b" in result
assert "glm-5" in result
assert "nemotron-3-nano:30b" in result
assert "some-embedding:latest" not in result # no tool_call
# ── Agent Init (no SyntaxError) ──
class TestOllamaCloudAgentInit:
def test_agent_imports_without_error(self):
"""Verify run_agent.py has no SyntaxError."""
import importlib
import run_agent
importlib.reload(run_agent)
def test_ollama_cloud_agent_uses_chat_completions(self, monkeypatch):
"""Ollama Cloud falls through to chat_completions — no special elif needed."""
monkeypatch.setenv("OLLAMA_API_KEY", "test-key")
with patch("run_agent.OpenAI") as mock_openai:
mock_openai.return_value = MagicMock()
from run_agent import AIAgent
agent = AIAgent(
model="qwen3.5:397b",
provider="ollama-cloud",
api_key="test-key",
base_url="https://ollama.com/v1",
)
assert agent.api_mode == "chat_completions"
assert agent.provider == "ollama-cloud"
# ── providers.py New System ──
class TestOllamaCloudProvidersNew:
def test_overlay_exists(self):
from hermes_cli.providers import HERMES_OVERLAYS
assert "ollama-cloud" in HERMES_OVERLAYS
overlay = HERMES_OVERLAYS["ollama-cloud"]
assert overlay.transport == "openai_chat"
assert overlay.base_url_env_var == "OLLAMA_BASE_URL"
def test_alias_resolves(self):
from hermes_cli.providers import normalize_provider as np
assert np("ollama") == "custom" # bare "ollama" = local
assert np("ollama-cloud") == "ollama-cloud"
def test_label_override(self):
from hermes_cli.providers import _LABEL_OVERRIDES
assert _LABEL_OVERRIDES.get("ollama-cloud") == "Ollama Cloud"
def test_get_label(self):
from hermes_cli.providers import get_label
assert get_label("ollama-cloud") == "Ollama Cloud"
def test_get_provider(self):
from hermes_cli.providers import get_provider
pdef = get_provider("ollama-cloud")
assert pdef is not None
assert pdef.id == "ollama-cloud"
assert pdef.transport == "openai_chat"
# ── Auxiliary Model ──
class TestOllamaCloudAuxiliary:
def test_aux_model_defined(self):
from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
assert "ollama-cloud" in _API_KEY_PROVIDER_AUX_MODELS
assert _API_KEY_PROVIDER_AUX_MODELS["ollama-cloud"] == "nemotron-3-nano:30b"

View File

@@ -18,6 +18,8 @@ from hermes_cli.plugins import (
PluginManager,
PluginManifest,
get_plugin_manager,
get_plugin_command_handler,
get_plugin_commands,
get_pre_tool_call_block_message,
discover_plugins,
invoke_hook,
@@ -605,7 +607,292 @@ class TestPreLlmCallTargetRouting:
assert "plain text C" in _plugin_user_context
# NOTE: TestPluginCommands removed register_command() was never implemented
# in PluginContext (hermes_cli/plugins.py). The tests referenced _plugin_commands,
# commands_registered, get_plugin_command_handler, and GATEWAY_KNOWN_COMMANDS
# integration — all of which are unimplemented features.
# ── TestPluginCommands ────────────────────────────────────────────────────
class TestPluginCommands:
"""Tests for plugin slash command registration via register_command()."""
def test_register_command_basic(self):
"""register_command() stores handler, description, and plugin name."""
mgr = PluginManager()
manifest = PluginManifest(name="test-plugin", source="user")
ctx = PluginContext(manifest, mgr)
handler = lambda args: f"echo {args}"
ctx.register_command("mycmd", handler, description="My custom command")
assert "mycmd" in mgr._plugin_commands
entry = mgr._plugin_commands["mycmd"]
assert entry["handler"] is handler
assert entry["description"] == "My custom command"
assert entry["plugin"] == "test-plugin"
def test_register_command_normalizes_name(self):
"""Names are lowercased, stripped, and leading slashes removed."""
mgr = PluginManager()
manifest = PluginManifest(name="test-plugin", source="user")
ctx = PluginContext(manifest, mgr)
ctx.register_command("/MyCmd ", lambda a: a, description="test")
assert "mycmd" in mgr._plugin_commands
assert "/MyCmd " not in mgr._plugin_commands
def test_register_command_empty_name_rejected(self, caplog):
"""Empty name after normalization is rejected with a warning."""
mgr = PluginManager()
manifest = PluginManifest(name="test-plugin", source="user")
ctx = PluginContext(manifest, mgr)
with caplog.at_level(logging.WARNING):
ctx.register_command("", lambda a: a)
assert len(mgr._plugin_commands) == 0
assert "empty name" in caplog.text
def test_register_command_builtin_conflict_rejected(self, caplog):
"""Commands that conflict with built-in names are rejected."""
mgr = PluginManager()
manifest = PluginManifest(name="test-plugin", source="user")
ctx = PluginContext(manifest, mgr)
with caplog.at_level(logging.WARNING):
ctx.register_command("help", lambda a: a)
assert "help" not in mgr._plugin_commands
assert "conflicts" in caplog.text.lower()
def test_register_command_default_description(self):
"""Missing description defaults to 'Plugin command'."""
mgr = PluginManager()
manifest = PluginManifest(name="test-plugin", source="user")
ctx = PluginContext(manifest, mgr)
ctx.register_command("status-cmd", lambda a: a)
assert mgr._plugin_commands["status-cmd"]["description"] == "Plugin command"
def test_get_plugin_command_handler_found(self):
"""get_plugin_command_handler() returns the handler for a registered command."""
mgr = PluginManager()
manifest = PluginManifest(name="test-plugin", source="user")
ctx = PluginContext(manifest, mgr)
handler = lambda args: f"result: {args}"
ctx.register_command("mycmd", handler, description="test")
with patch("hermes_cli.plugins._plugin_manager", mgr):
result = get_plugin_command_handler("mycmd")
assert result is handler
def test_get_plugin_command_handler_not_found(self):
"""get_plugin_command_handler() returns None for unregistered commands."""
mgr = PluginManager()
with patch("hermes_cli.plugins._plugin_manager", mgr):
assert get_plugin_command_handler("nonexistent") is None
def test_get_plugin_commands_returns_dict(self):
"""get_plugin_commands() returns the full commands dict."""
mgr = PluginManager()
manifest = PluginManifest(name="test-plugin", source="user")
ctx = PluginContext(manifest, mgr)
ctx.register_command("cmd-a", lambda a: a, description="A")
ctx.register_command("cmd-b", lambda a: a, description="B")
with patch("hermes_cli.plugins._plugin_manager", mgr):
cmds = get_plugin_commands()
assert "cmd-a" in cmds
assert "cmd-b" in cmds
assert cmds["cmd-a"]["description"] == "A"
def test_commands_tracked_on_loaded_plugin(self, tmp_path, monkeypatch):
"""Commands registered during discover_and_load() are tracked on LoadedPlugin."""
plugins_dir = tmp_path / "hermes_test" / "plugins"
_make_plugin_dir(
plugins_dir, "cmd-plugin",
register_body=(
'ctx.register_command("mycmd", lambda a: "ok", description="Test")'
),
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
mgr = PluginManager()
mgr.discover_and_load()
loaded = mgr._plugins["cmd-plugin"]
assert loaded.enabled
assert "mycmd" in loaded.commands_registered
def test_commands_in_list_plugins_output(self, tmp_path, monkeypatch):
"""list_plugins() includes command count."""
plugins_dir = tmp_path / "hermes_test" / "plugins"
_make_plugin_dir(
plugins_dir, "cmd-plugin",
register_body=(
'ctx.register_command("mycmd", lambda a: "ok", description="Test")'
),
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
mgr = PluginManager()
mgr.discover_and_load()
info = mgr.list_plugins()
assert len(info) == 1
assert info[0]["commands"] == 1
def test_handler_receives_raw_args(self):
"""The handler is called with the raw argument string."""
mgr = PluginManager()
manifest = PluginManifest(name="test-plugin", source="user")
ctx = PluginContext(manifest, mgr)
received = []
ctx.register_command("echo", lambda args: received.append(args) or "ok")
handler = mgr._plugin_commands["echo"]["handler"]
handler("hello world")
assert received == ["hello world"]
def test_multiple_plugins_register_different_commands(self):
"""Multiple plugins can each register their own commands."""
mgr = PluginManager()
for plugin_name, cmd_name in [("plugin-a", "cmd-a"), ("plugin-b", "cmd-b")]:
manifest = PluginManifest(name=plugin_name, source="user")
ctx = PluginContext(manifest, mgr)
ctx.register_command(cmd_name, lambda a: a, description=f"From {plugin_name}")
assert "cmd-a" in mgr._plugin_commands
assert "cmd-b" in mgr._plugin_commands
assert mgr._plugin_commands["cmd-a"]["plugin"] == "plugin-a"
assert mgr._plugin_commands["cmd-b"]["plugin"] == "plugin-b"
# ── TestPluginDispatchTool ────────────────────────────────────────────────
class TestPluginDispatchTool:
"""Tests for PluginContext.dispatch_tool() — tool dispatch with agent context."""
def test_dispatch_tool_calls_registry(self):
"""dispatch_tool() delegates to registry.dispatch()."""
mgr = PluginManager()
manifest = PluginManifest(name="test-plugin", source="user")
ctx = PluginContext(manifest, mgr)
mock_registry = MagicMock()
mock_registry.dispatch.return_value = '{"result": "ok"}'
with patch("hermes_cli.plugins.PluginContext.dispatch_tool.__module__", "hermes_cli.plugins"):
with patch.dict("sys.modules", {}):
with patch("tools.registry.registry", mock_registry):
result = ctx.dispatch_tool("web_search", {"query": "test"})
assert result == '{"result": "ok"}'
def test_dispatch_tool_injects_parent_agent_from_cli_ref(self):
"""When _cli_ref has an agent, it's passed as parent_agent."""
mgr = PluginManager()
manifest = PluginManifest(name="test-plugin", source="user")
ctx = PluginContext(manifest, mgr)
mock_agent = MagicMock()
mock_cli = MagicMock()
mock_cli.agent = mock_agent
mgr._cli_ref = mock_cli
mock_registry = MagicMock()
mock_registry.dispatch.return_value = '{"ok": true}'
with patch("tools.registry.registry", mock_registry):
ctx.dispatch_tool("delegate_task", {"goal": "test"})
mock_registry.dispatch.assert_called_once()
call_kwargs = mock_registry.dispatch.call_args
assert call_kwargs[1].get("parent_agent") is mock_agent
def test_dispatch_tool_no_parent_agent_when_no_cli_ref(self):
"""When _cli_ref is None (gateway mode), no parent_agent is injected."""
mgr = PluginManager()
manifest = PluginManifest(name="test-plugin", source="user")
ctx = PluginContext(manifest, mgr)
mgr._cli_ref = None
mock_registry = MagicMock()
mock_registry.dispatch.return_value = '{"ok": true}'
with patch("tools.registry.registry", mock_registry):
ctx.dispatch_tool("delegate_task", {"goal": "test"})
call_kwargs = mock_registry.dispatch.call_args
assert "parent_agent" not in call_kwargs[1]
def test_dispatch_tool_no_parent_agent_when_agent_is_none(self):
"""When cli_ref exists but agent is None (not yet initialized), skip parent_agent."""
mgr = PluginManager()
manifest = PluginManifest(name="test-plugin", source="user")
ctx = PluginContext(manifest, mgr)
mock_cli = MagicMock()
mock_cli.agent = None
mgr._cli_ref = mock_cli
mock_registry = MagicMock()
mock_registry.dispatch.return_value = '{"ok": true}'
with patch("tools.registry.registry", mock_registry):
ctx.dispatch_tool("delegate_task", {"goal": "test"})
call_kwargs = mock_registry.dispatch.call_args
assert "parent_agent" not in call_kwargs[1]
def test_dispatch_tool_respects_explicit_parent_agent(self):
"""Explicit parent_agent kwarg is not overwritten by _cli_ref.agent."""
mgr = PluginManager()
manifest = PluginManifest(name="test-plugin", source="user")
ctx = PluginContext(manifest, mgr)
cli_agent = MagicMock(name="cli_agent")
mock_cli = MagicMock()
mock_cli.agent = cli_agent
mgr._cli_ref = mock_cli
explicit_agent = MagicMock(name="explicit_agent")
mock_registry = MagicMock()
mock_registry.dispatch.return_value = '{"ok": true}'
with patch("tools.registry.registry", mock_registry):
ctx.dispatch_tool("delegate_task", {"goal": "test"}, parent_agent=explicit_agent)
call_kwargs = mock_registry.dispatch.call_args
assert call_kwargs[1]["parent_agent"] is explicit_agent
def test_dispatch_tool_forwards_extra_kwargs(self):
"""Extra kwargs are forwarded to registry.dispatch()."""
mgr = PluginManager()
manifest = PluginManifest(name="test-plugin", source="user")
ctx = PluginContext(manifest, mgr)
mgr._cli_ref = None
mock_registry = MagicMock()
mock_registry.dispatch.return_value = '{"ok": true}'
with patch("tools.registry.registry", mock_registry):
ctx.dispatch_tool("some_tool", {"x": 1}, task_id="test-123")
call_kwargs = mock_registry.dispatch.call_args
assert call_kwargs[1]["task_id"] == "test-123"
def test_dispatch_tool_returns_json_string(self):
"""dispatch_tool() returns the raw JSON string from the registry."""
mgr = PluginManager()
manifest = PluginManifest(name="test-plugin", source="user")
ctx = PluginContext(manifest, mgr)
mgr._cli_ref = None
mock_registry = MagicMock()
mock_registry.dispatch.return_value = '{"error": "Unknown tool: fake"}'
with patch("tools.registry.registry", mock_registry):
result = ctx.dispatch_tool("fake", {})
assert '"error"' in result