Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
@@ -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()
|
||||
|
||||
101
tests/hermes_cli/test_model_switch_copilot_api_mode.py
Normal file
101
tests/hermes_cli/test_model_switch_copilot_api_mode.py
Normal 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"
|
||||
@@ -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"
|
||||
|
||||
351
tests/hermes_cli/test_ollama_cloud_provider.py
Normal file
351
tests/hermes_cli/test_ollama_cloud_provider.py
Normal 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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user