Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
@@ -1,17 +1,9 @@
|
||||
"""Tests for API-key provider support (z.ai/GLM, Kimi, MiniMax, AI Gateway)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure dotenv doesn't interfere
|
||||
if "dotenv" not in sys.modules:
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
sys.modules["dotenv"] = fake_dotenv
|
||||
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
ProviderConfig,
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
"""Tests for Arcee AI provider support — standard direct API provider."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
if "dotenv" not in sys.modules:
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
sys.modules["dotenv"] = fake_dotenv
|
||||
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
resolve_provider,
|
||||
|
||||
@@ -57,85 +57,6 @@ def _build_parser():
|
||||
return parser
|
||||
|
||||
|
||||
class TestFlagBeforeSubcommand:
|
||||
"""Flags placed before 'chat' must propagate through."""
|
||||
|
||||
def test_yolo_before_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["--yolo", "chat"])
|
||||
assert getattr(args, "yolo", False) is True
|
||||
|
||||
def test_worktree_before_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["-w", "chat"])
|
||||
assert getattr(args, "worktree", False) is True
|
||||
|
||||
def test_skills_before_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["-s", "myskill", "chat"])
|
||||
assert getattr(args, "skills", None) == ["myskill"]
|
||||
|
||||
def test_pass_session_id_before_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["--pass-session-id", "chat"])
|
||||
assert getattr(args, "pass_session_id", False) is True
|
||||
|
||||
def test_resume_before_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["-r", "abc123", "chat"])
|
||||
assert getattr(args, "resume", None) == "abc123"
|
||||
|
||||
|
||||
class TestFlagAfterSubcommand:
|
||||
"""Flags placed after 'chat' must still work."""
|
||||
|
||||
def test_yolo_after_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["chat", "--yolo"])
|
||||
assert getattr(args, "yolo", False) is True
|
||||
|
||||
def test_worktree_after_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["chat", "-w"])
|
||||
assert getattr(args, "worktree", False) is True
|
||||
|
||||
def test_skills_after_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["chat", "-s", "myskill"])
|
||||
assert getattr(args, "skills", None) == ["myskill"]
|
||||
|
||||
def test_resume_after_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["chat", "-r", "abc123"])
|
||||
assert getattr(args, "resume", None) == "abc123"
|
||||
|
||||
|
||||
class TestNoSubcommandDefaults:
|
||||
"""When no subcommand is given, flags must work and defaults must hold."""
|
||||
|
||||
def test_yolo_no_subcommand(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["--yolo"])
|
||||
assert args.yolo is True
|
||||
assert args.command is None
|
||||
|
||||
def test_defaults_no_flags(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args([])
|
||||
assert getattr(args, "yolo", False) is False
|
||||
assert getattr(args, "worktree", False) is False
|
||||
assert getattr(args, "skills", None) is None
|
||||
assert getattr(args, "resume", None) is None
|
||||
|
||||
def test_defaults_chat_no_flags(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["chat"])
|
||||
# With SUPPRESS, these fall through to parent defaults
|
||||
assert getattr(args, "yolo", False) is False
|
||||
assert getattr(args, "worktree", False) is False
|
||||
assert getattr(args, "skills", None) is None
|
||||
|
||||
|
||||
class TestYoloEnvVar:
|
||||
"""Verify --yolo sets HERMES_YOLO_MODE regardless of flag position.
|
||||
|
||||
|
||||
@@ -703,3 +703,231 @@ def test_auth_remove_claude_code_suppresses_reseed(tmp_path, monkeypatch):
|
||||
suppressed = updated.get("suppressed_sources", {})
|
||||
assert "anthropic" in suppressed
|
||||
assert "claude_code" in suppressed["anthropic"]
|
||||
|
||||
|
||||
def test_unsuppress_credential_source_clears_marker(tmp_path, monkeypatch):
|
||||
"""unsuppress_credential_source() removes a previously-set marker."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1})
|
||||
|
||||
from hermes_cli.auth import suppress_credential_source, unsuppress_credential_source, is_source_suppressed
|
||||
|
||||
suppress_credential_source("openai-codex", "device_code")
|
||||
assert is_source_suppressed("openai-codex", "device_code") is True
|
||||
|
||||
cleared = unsuppress_credential_source("openai-codex", "device_code")
|
||||
assert cleared is True
|
||||
assert is_source_suppressed("openai-codex", "device_code") is False
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
# Empty suppressed_sources dict should be cleaned up entirely
|
||||
assert "suppressed_sources" not in payload
|
||||
|
||||
|
||||
def test_unsuppress_credential_source_returns_false_when_absent(tmp_path, monkeypatch):
|
||||
"""unsuppress_credential_source() returns False if no marker exists."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1})
|
||||
|
||||
from hermes_cli.auth import unsuppress_credential_source
|
||||
|
||||
assert unsuppress_credential_source("openai-codex", "device_code") is False
|
||||
assert unsuppress_credential_source("nonexistent", "whatever") is False
|
||||
|
||||
|
||||
def test_unsuppress_credential_source_preserves_other_markers(tmp_path, monkeypatch):
|
||||
"""Clearing one marker must not affect unrelated markers."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1})
|
||||
|
||||
from hermes_cli.auth import (
|
||||
suppress_credential_source,
|
||||
unsuppress_credential_source,
|
||||
is_source_suppressed,
|
||||
)
|
||||
|
||||
suppress_credential_source("openai-codex", "device_code")
|
||||
suppress_credential_source("anthropic", "claude_code")
|
||||
|
||||
assert unsuppress_credential_source("openai-codex", "device_code") is True
|
||||
assert is_source_suppressed("anthropic", "claude_code") is True
|
||||
|
||||
|
||||
def test_auth_remove_codex_device_code_suppresses_reseed(tmp_path, monkeypatch):
|
||||
"""Removing an auto-seeded openai-codex credential must mark the source as suppressed."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_singletons",
|
||||
lambda provider, entries: (False, {"device_code"}),
|
||||
)
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
auth_store = {
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {
|
||||
"access_token": "acc-1",
|
||||
"refresh_token": "ref-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"credential_pool": {
|
||||
"openai-codex": [{
|
||||
"id": "cx1",
|
||||
"label": "codex-auto",
|
||||
"auth_type": "oauth",
|
||||
"priority": 0,
|
||||
"source": "device_code",
|
||||
"access_token": "acc-1",
|
||||
"refresh_token": "ref-1",
|
||||
}]
|
||||
},
|
||||
}
|
||||
(hermes_home / "auth.json").write_text(json.dumps(auth_store))
|
||||
|
||||
from types import SimpleNamespace
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
|
||||
auth_remove_command(SimpleNamespace(provider="openai-codex", target="1"))
|
||||
|
||||
updated = json.loads((hermes_home / "auth.json").read_text())
|
||||
suppressed = updated.get("suppressed_sources", {})
|
||||
assert "openai-codex" in suppressed
|
||||
assert "device_code" in suppressed["openai-codex"]
|
||||
# Tokens in providers state should also be cleared
|
||||
assert "openai-codex" not in updated.get("providers", {})
|
||||
|
||||
|
||||
def test_auth_remove_codex_manual_source_suppresses_reseed(tmp_path, monkeypatch):
|
||||
"""Removing a manually-added (`manual:device_code`) openai-codex credential must also suppress."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_singletons",
|
||||
lambda provider, entries: (False, set()),
|
||||
)
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
auth_store = {
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {
|
||||
"access_token": "acc-2",
|
||||
"refresh_token": "ref-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"credential_pool": {
|
||||
"openai-codex": [{
|
||||
"id": "cx2",
|
||||
"label": "manual-codex",
|
||||
"auth_type": "oauth",
|
||||
"priority": 0,
|
||||
"source": "manual:device_code",
|
||||
"access_token": "acc-2",
|
||||
"refresh_token": "ref-2",
|
||||
}]
|
||||
},
|
||||
}
|
||||
(hermes_home / "auth.json").write_text(json.dumps(auth_store))
|
||||
|
||||
from types import SimpleNamespace
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
|
||||
auth_remove_command(SimpleNamespace(provider="openai-codex", target="1"))
|
||||
|
||||
updated = json.loads((hermes_home / "auth.json").read_text())
|
||||
suppressed = updated.get("suppressed_sources", {})
|
||||
# Critical: manual:device_code source must also trigger the suppression path
|
||||
assert "openai-codex" in suppressed
|
||||
assert "device_code" in suppressed["openai-codex"]
|
||||
assert "openai-codex" not in updated.get("providers", {})
|
||||
|
||||
|
||||
def test_auth_add_codex_clears_suppression_marker(tmp_path, monkeypatch):
|
||||
"""Re-linking codex via `hermes auth add openai-codex` must clear any suppression marker."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_singletons",
|
||||
lambda provider, entries: (False, set()),
|
||||
)
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Pre-existing suppression (simulating a prior `hermes auth remove`)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"suppressed_sources": {"openai-codex": ["device_code"]},
|
||||
}))
|
||||
|
||||
token = _jwt_with_email("codex@example.com")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._codex_device_code_login",
|
||||
lambda: {
|
||||
"tokens": {
|
||||
"access_token": token,
|
||||
"refresh_token": "refreshed",
|
||||
},
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"last_refresh": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_add_command
|
||||
|
||||
class _Args:
|
||||
provider = "openai-codex"
|
||||
auth_type = "oauth"
|
||||
api_key = None
|
||||
label = None
|
||||
|
||||
auth_add_command(_Args())
|
||||
|
||||
payload = json.loads((hermes_home / "auth.json").read_text())
|
||||
# Suppression marker must be cleared
|
||||
assert "openai-codex" not in payload.get("suppressed_sources", {})
|
||||
# New pool entry must be present
|
||||
entries = payload["credential_pool"]["openai-codex"]
|
||||
assert any(e["source"] == "manual:device_code" for e in entries)
|
||||
|
||||
|
||||
def test_seed_from_singletons_respects_codex_suppression(tmp_path, monkeypatch):
|
||||
"""_seed_from_singletons() for openai-codex must skip auto-import when suppressed."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Suppression marker in place
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"suppressed_sources": {"openai-codex": ["device_code"]},
|
||||
}))
|
||||
|
||||
# Make _import_codex_cli_tokens return tokens — these would normally trigger
|
||||
# a re-seed, but suppression must skip it.
|
||||
def _fake_import():
|
||||
return {
|
||||
"access_token": "would-be-reimported",
|
||||
"refresh_token": "would-be-reimported",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth._import_codex_cli_tokens", _fake_import)
|
||||
|
||||
from agent.credential_pool import _seed_from_singletons
|
||||
|
||||
entries = []
|
||||
changed, active_sources = _seed_from_singletons("openai-codex", entries)
|
||||
|
||||
# With suppression in place: nothing changes, no entries added, no sources
|
||||
assert changed is False
|
||||
assert entries == []
|
||||
assert active_sources == set()
|
||||
|
||||
# Verify the auth store was NOT modified (no auto-import happened)
|
||||
after = json.loads((hermes_home / "auth.json").read_text())
|
||||
assert "openai-codex" not in after.get("providers", {})
|
||||
|
||||
@@ -299,3 +299,160 @@ def test_mint_retry_uses_latest_rotated_refresh_token(tmp_path, monkeypatch):
|
||||
assert creds["api_key"] == "agent-key"
|
||||
assert refresh_calls == ["refresh-old", "refresh-1"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _login_nous: "Skip (keep current)" must preserve prior provider + model
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestLoginNousSkipKeepsCurrent:
|
||||
"""When a user runs `hermes model` → Nous Portal → Skip (keep current) after
|
||||
a successful OAuth login, the prior provider and model MUST be preserved.
|
||||
|
||||
Regression: previously, _update_config_for_provider was called
|
||||
unconditionally after login, which flipped model.provider to "nous" while
|
||||
keeping the old model.default (e.g. anthropic/claude-opus-4.6 from
|
||||
OpenRouter), leaving the user with a mismatched provider/model pair.
|
||||
"""
|
||||
|
||||
def _setup_home_with_openrouter(self, tmp_path, monkeypatch):
|
||||
import yaml
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(yaml.safe_dump({
|
||||
"model": {
|
||||
"provider": "openrouter",
|
||||
"default": "anthropic/claude-opus-4.6",
|
||||
},
|
||||
}, sort_keys=False))
|
||||
|
||||
auth_path = hermes_home / "auth.json"
|
||||
auth_path.write_text(json.dumps({
|
||||
"version": 1,
|
||||
"active_provider": "openrouter",
|
||||
"providers": {"openrouter": {"api_key": "sk-or-fake"}},
|
||||
}))
|
||||
return hermes_home, config_path, auth_path
|
||||
|
||||
def _patch_login_internals(self, monkeypatch, *, prompt_returns):
|
||||
"""Patch OAuth + model-list + prompt so _login_nous doesn't hit network."""
|
||||
import hermes_cli.auth as auth_mod
|
||||
import hermes_cli.models as models_mod
|
||||
import hermes_cli.nous_subscription as ns
|
||||
|
||||
fake_auth_state = {
|
||||
"access_token": "fake-nous-token",
|
||||
"agent_key": "fake-agent-key",
|
||||
"inference_base_url": "https://inference-api.nousresearch.com",
|
||||
"portal_base_url": "https://portal.nousresearch.com",
|
||||
"refresh_token": "fake-refresh",
|
||||
"token_expires_at": 9999999999,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
auth_mod, "_nous_device_code_login",
|
||||
lambda **kwargs: dict(fake_auth_state),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth_mod, "_prompt_model_selection",
|
||||
lambda *a, **kw: prompt_returns,
|
||||
)
|
||||
monkeypatch.setattr(models_mod, "get_pricing_for_provider", lambda p: {})
|
||||
monkeypatch.setattr(models_mod, "filter_nous_free_models", lambda ids, p: ids)
|
||||
monkeypatch.setattr(models_mod, "check_nous_free_tier", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
models_mod, "partition_nous_models_by_tier",
|
||||
lambda ids, p, free_tier=False: (ids, []),
|
||||
)
|
||||
monkeypatch.setattr(ns, "prompt_enable_tool_gateway", lambda cfg: None)
|
||||
|
||||
def test_skip_keep_current_preserves_provider_and_model(self, tmp_path, monkeypatch):
|
||||
"""User picks Skip → config.yaml untouched, Nous creds still saved."""
|
||||
import argparse
|
||||
import yaml
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, _login_nous
|
||||
|
||||
hermes_home, config_path, auth_path = self._setup_home_with_openrouter(
|
||||
tmp_path, monkeypatch,
|
||||
)
|
||||
self._patch_login_internals(monkeypatch, prompt_returns=None)
|
||||
|
||||
args = argparse.Namespace(
|
||||
portal_url=None, inference_url=None, client_id=None, scope=None,
|
||||
no_browser=True, timeout=15.0, ca_bundle=None, insecure=False,
|
||||
)
|
||||
_login_nous(args, PROVIDER_REGISTRY["nous"])
|
||||
|
||||
# config.yaml model section must be unchanged
|
||||
cfg_after = yaml.safe_load(config_path.read_text())
|
||||
assert cfg_after["model"]["provider"] == "openrouter"
|
||||
assert cfg_after["model"]["default"] == "anthropic/claude-opus-4.6"
|
||||
assert "base_url" not in cfg_after["model"]
|
||||
|
||||
# auth.json: active_provider restored to openrouter, but Nous creds saved
|
||||
auth_after = json.loads(auth_path.read_text())
|
||||
assert auth_after["active_provider"] == "openrouter"
|
||||
assert "nous" in auth_after["providers"]
|
||||
assert auth_after["providers"]["nous"]["access_token"] == "fake-nous-token"
|
||||
# Existing openrouter creds still intact
|
||||
assert auth_after["providers"]["openrouter"]["api_key"] == "sk-or-fake"
|
||||
|
||||
def test_picking_model_switches_to_nous(self, tmp_path, monkeypatch):
|
||||
"""User picks a Nous model → provider flips to nous with that model."""
|
||||
import argparse
|
||||
import yaml
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, _login_nous
|
||||
|
||||
hermes_home, config_path, auth_path = self._setup_home_with_openrouter(
|
||||
tmp_path, monkeypatch,
|
||||
)
|
||||
self._patch_login_internals(
|
||||
monkeypatch, prompt_returns="xiaomi/mimo-v2-pro",
|
||||
)
|
||||
|
||||
args = argparse.Namespace(
|
||||
portal_url=None, inference_url=None, client_id=None, scope=None,
|
||||
no_browser=True, timeout=15.0, ca_bundle=None, insecure=False,
|
||||
)
|
||||
_login_nous(args, PROVIDER_REGISTRY["nous"])
|
||||
|
||||
cfg_after = yaml.safe_load(config_path.read_text())
|
||||
assert cfg_after["model"]["provider"] == "nous"
|
||||
assert cfg_after["model"]["default"] == "xiaomi/mimo-v2-pro"
|
||||
|
||||
auth_after = json.loads(auth_path.read_text())
|
||||
assert auth_after["active_provider"] == "nous"
|
||||
|
||||
def test_skip_with_no_prior_active_provider_clears_it(self, tmp_path, monkeypatch):
|
||||
"""Fresh install (no prior active_provider) → Skip clears active_provider
|
||||
instead of leaving it as nous."""
|
||||
import argparse
|
||||
import yaml
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, _login_nous
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(yaml.safe_dump({"model": {}}, sort_keys=False))
|
||||
|
||||
# No auth.json yet — simulates first-run before any OAuth
|
||||
self._patch_login_internals(monkeypatch, prompt_returns=None)
|
||||
|
||||
args = argparse.Namespace(
|
||||
portal_url=None, inference_url=None, client_id=None, scope=None,
|
||||
no_browser=True, timeout=15.0, ca_bundle=None, insecure=False,
|
||||
)
|
||||
_login_nous(args, PROVIDER_REGISTRY["nous"])
|
||||
|
||||
auth_path = hermes_home / "auth.json"
|
||||
auth_after = json.loads(auth_path.read_text())
|
||||
# active_provider should NOT be set to "nous" after Skip
|
||||
assert auth_after.get("active_provider") in (None, "")
|
||||
# But Nous creds are still saved
|
||||
assert "nous" in auth_after.get("providers", {})
|
||||
|
||||
|
||||
|
||||
@@ -449,20 +449,6 @@ class TestRunDebug:
|
||||
# Argparse integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestArgparseIntegration:
|
||||
def test_module_imports_clean(self):
|
||||
from hermes_cli.debug import run_debug, run_debug_share
|
||||
assert callable(run_debug)
|
||||
assert callable(run_debug_share)
|
||||
|
||||
def test_cmd_debug_dispatches(self):
|
||||
from hermes_cli.main import cmd_debug
|
||||
|
||||
args = MagicMock()
|
||||
args.debug_command = None
|
||||
cmd_debug(args)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delete / auto-delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
217
tests/hermes_cli/test_dingtalk_auth.py
Normal file
217
tests/hermes_cli/test_dingtalk_auth.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""Unit tests for hermes_cli/dingtalk_auth.py (QR device-flow registration)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API layer — _api_post + error mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApiPost:
|
||||
|
||||
def test_raises_on_network_error(self):
|
||||
import requests
|
||||
from hermes_cli.dingtalk_auth import _api_post, RegistrationError
|
||||
|
||||
with patch("hermes_cli.dingtalk_auth.requests.post",
|
||||
side_effect=requests.ConnectionError("nope")):
|
||||
with pytest.raises(RegistrationError, match="Network error"):
|
||||
_api_post("/app/registration/init", {"source": "hermes"})
|
||||
|
||||
def test_raises_on_nonzero_errcode(self):
|
||||
from hermes_cli.dingtalk_auth import _api_post, RegistrationError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"errcode": 42, "errmsg": "boom"}
|
||||
|
||||
with patch("hermes_cli.dingtalk_auth.requests.post", return_value=mock_resp):
|
||||
with pytest.raises(RegistrationError, match=r"boom \(errcode=42\)"):
|
||||
_api_post("/app/registration/init", {"source": "hermes"})
|
||||
|
||||
def test_returns_data_on_success(self):
|
||||
from hermes_cli.dingtalk_auth import _api_post
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"errcode": 0, "nonce": "abc"}
|
||||
|
||||
with patch("hermes_cli.dingtalk_auth.requests.post", return_value=mock_resp):
|
||||
result = _api_post("/app/registration/init", {"source": "hermes"})
|
||||
assert result["nonce"] == "abc"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# begin_registration — 2-step nonce → device_code chain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBeginRegistration:
|
||||
|
||||
def test_chains_init_then_begin(self):
|
||||
from hermes_cli.dingtalk_auth import begin_registration
|
||||
|
||||
responses = [
|
||||
{"errcode": 0, "nonce": "nonce123"},
|
||||
{
|
||||
"errcode": 0,
|
||||
"device_code": "dev-xyz",
|
||||
"verification_uri_complete": "https://open-dev.dingtalk.com/openapp/registration/openClaw?user_code=ABCD",
|
||||
"expires_in": 7200,
|
||||
"interval": 2,
|
||||
},
|
||||
]
|
||||
with patch("hermes_cli.dingtalk_auth._api_post", side_effect=responses):
|
||||
result = begin_registration()
|
||||
|
||||
assert result["device_code"] == "dev-xyz"
|
||||
assert "verification_uri_complete" in result
|
||||
assert result["interval"] == 2
|
||||
assert result["expires_in"] == 7200
|
||||
|
||||
def test_missing_nonce_raises(self):
|
||||
from hermes_cli.dingtalk_auth import begin_registration, RegistrationError
|
||||
|
||||
with patch("hermes_cli.dingtalk_auth._api_post",
|
||||
return_value={"errcode": 0, "nonce": ""}):
|
||||
with pytest.raises(RegistrationError, match="missing nonce"):
|
||||
begin_registration()
|
||||
|
||||
def test_missing_device_code_raises(self):
|
||||
from hermes_cli.dingtalk_auth import begin_registration, RegistrationError
|
||||
|
||||
responses = [
|
||||
{"errcode": 0, "nonce": "n1"},
|
||||
{"errcode": 0, "verification_uri_complete": "http://x"}, # no device_code
|
||||
]
|
||||
with patch("hermes_cli.dingtalk_auth._api_post", side_effect=responses):
|
||||
with pytest.raises(RegistrationError, match="missing device_code"):
|
||||
begin_registration()
|
||||
|
||||
def test_missing_verification_uri_raises(self):
|
||||
from hermes_cli.dingtalk_auth import begin_registration, RegistrationError
|
||||
|
||||
responses = [
|
||||
{"errcode": 0, "nonce": "n1"},
|
||||
{"errcode": 0, "device_code": "dev"}, # no verification_uri_complete
|
||||
]
|
||||
with patch("hermes_cli.dingtalk_auth._api_post", side_effect=responses):
|
||||
with pytest.raises(RegistrationError,
|
||||
match="missing verification_uri_complete"):
|
||||
begin_registration()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wait_for_registration_success — polling loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWaitForSuccess:
|
||||
|
||||
def test_returns_credentials_on_success(self):
|
||||
from hermes_cli.dingtalk_auth import wait_for_registration_success
|
||||
|
||||
responses = [
|
||||
{"status": "WAITING"},
|
||||
{"status": "WAITING"},
|
||||
{"status": "SUCCESS", "client_id": "cid-1", "client_secret": "sec-1"},
|
||||
]
|
||||
with patch("hermes_cli.dingtalk_auth.poll_registration", side_effect=responses), \
|
||||
patch("hermes_cli.dingtalk_auth.time.sleep"):
|
||||
cid, secret = wait_for_registration_success(
|
||||
device_code="dev", interval=0, expires_in=60
|
||||
)
|
||||
assert cid == "cid-1"
|
||||
assert secret == "sec-1"
|
||||
|
||||
def test_success_without_credentials_raises(self):
|
||||
from hermes_cli.dingtalk_auth import wait_for_registration_success, RegistrationError
|
||||
|
||||
with patch("hermes_cli.dingtalk_auth.poll_registration",
|
||||
return_value={"status": "SUCCESS", "client_id": "", "client_secret": ""}), \
|
||||
patch("hermes_cli.dingtalk_auth.time.sleep"):
|
||||
with pytest.raises(RegistrationError, match="credentials are missing"):
|
||||
wait_for_registration_success(
|
||||
device_code="dev", interval=0, expires_in=60
|
||||
)
|
||||
|
||||
def test_invokes_waiting_callback(self):
|
||||
from hermes_cli.dingtalk_auth import wait_for_registration_success
|
||||
|
||||
callback = MagicMock()
|
||||
responses = [
|
||||
{"status": "WAITING"},
|
||||
{"status": "WAITING"},
|
||||
{"status": "SUCCESS", "client_id": "cid", "client_secret": "sec"},
|
||||
]
|
||||
with patch("hermes_cli.dingtalk_auth.poll_registration", side_effect=responses), \
|
||||
patch("hermes_cli.dingtalk_auth.time.sleep"):
|
||||
wait_for_registration_success(
|
||||
device_code="dev", interval=0, expires_in=60, on_waiting=callback
|
||||
)
|
||||
assert callback.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QR rendering — terminal output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRenderQR:
|
||||
|
||||
def test_returns_false_when_qrcode_missing(self, monkeypatch):
|
||||
from hermes_cli import dingtalk_auth
|
||||
|
||||
# Simulate qrcode import failure
|
||||
monkeypatch.setitem(sys.modules, "qrcode", None)
|
||||
assert dingtalk_auth.render_qr_to_terminal("https://example.com") is False
|
||||
|
||||
def test_prints_when_qrcode_available(self, capsys):
|
||||
"""End-to-end: render a real QR and verify SOMETHING got printed."""
|
||||
try:
|
||||
import qrcode # noqa: F401
|
||||
except ImportError:
|
||||
pytest.skip("qrcode library not available")
|
||||
|
||||
from hermes_cli.dingtalk_auth import render_qr_to_terminal
|
||||
result = render_qr_to_terminal("https://example.com/test")
|
||||
captured = capsys.readouterr()
|
||||
assert result is True
|
||||
assert len(captured.out) > 100 # rendered matrix is non-trivial
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration — env var overrides
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfigOverrides:
|
||||
|
||||
def test_base_url_default(self, monkeypatch):
|
||||
monkeypatch.delenv("DINGTALK_REGISTRATION_BASE_URL", raising=False)
|
||||
# Force module reload to pick up current env
|
||||
import importlib
|
||||
import hermes_cli.dingtalk_auth as mod
|
||||
importlib.reload(mod)
|
||||
assert mod.REGISTRATION_BASE_URL == "https://oapi.dingtalk.com"
|
||||
|
||||
def test_base_url_override_via_env(self, monkeypatch):
|
||||
monkeypatch.setenv("DINGTALK_REGISTRATION_BASE_URL",
|
||||
"https://test.example.com/")
|
||||
import importlib
|
||||
import hermes_cli.dingtalk_auth as mod
|
||||
importlib.reload(mod)
|
||||
# Trailing slash stripped
|
||||
assert mod.REGISTRATION_BASE_URL == "https://test.example.com"
|
||||
|
||||
def test_source_default(self, monkeypatch):
|
||||
monkeypatch.delenv("DINGTALK_REGISTRATION_SOURCE", raising=False)
|
||||
import importlib
|
||||
import hermes_cli.dingtalk_auth as mod
|
||||
importlib.reload(mod)
|
||||
assert mod.REGISTRATION_SOURCE == "openClaw"
|
||||
@@ -539,3 +539,64 @@ class TestDispatcher:
|
||||
mcp_command(_make_args(mcp_action=None))
|
||||
out = capsys.readouterr().out
|
||||
assert "Commands:" in out or "No MCP servers" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Task 7 consolidation — cmd_mcp_remove evicts manager cache,
|
||||
# cmd_mcp_login forces re-auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMcpRemoveEvictsManager:
|
||||
def test_remove_evicts_in_memory_provider(self, tmp_path, capsys, monkeypatch):
|
||||
"""After cmd_mcp_remove, the MCPOAuthManager no longer caches the provider."""
|
||||
_seed_config(tmp_path, {
|
||||
"oauth-srv": {"url": "https://example.com/mcp", "auth": "oauth"},
|
||||
})
|
||||
monkeypatch.setattr("builtins.input", lambda _: "y")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.mcp_config.get_hermes_home", lambda: tmp_path
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
from tools.mcp_oauth_manager import get_manager, reset_manager_for_tests
|
||||
reset_manager_for_tests()
|
||||
|
||||
mgr = get_manager()
|
||||
mgr.get_or_build_provider(
|
||||
"oauth-srv", "https://example.com/mcp", None,
|
||||
)
|
||||
assert "oauth-srv" in mgr._entries
|
||||
|
||||
from hermes_cli.mcp_config import cmd_mcp_remove
|
||||
cmd_mcp_remove(_make_args(name="oauth-srv"))
|
||||
|
||||
assert "oauth-srv" not in mgr._entries
|
||||
|
||||
|
||||
class TestMcpLogin:
|
||||
def test_login_rejects_unknown_server(self, tmp_path, capsys):
|
||||
_seed_config(tmp_path, {})
|
||||
from hermes_cli.mcp_config import cmd_mcp_login
|
||||
cmd_mcp_login(_make_args(name="ghost"))
|
||||
out = capsys.readouterr().out
|
||||
assert "not found" in out
|
||||
|
||||
def test_login_rejects_non_oauth_server(self, tmp_path, capsys):
|
||||
_seed_config(tmp_path, {
|
||||
"srv": {"url": "https://example.com/mcp", "auth": "header"},
|
||||
})
|
||||
from hermes_cli.mcp_config import cmd_mcp_login
|
||||
cmd_mcp_login(_make_args(name="srv"))
|
||||
out = capsys.readouterr().out
|
||||
assert "not configured for OAuth" in out
|
||||
|
||||
def test_login_rejects_stdio_server(self, tmp_path, capsys):
|
||||
_seed_config(tmp_path, {
|
||||
"srv": {"command": "npx", "args": ["some-server"]},
|
||||
})
|
||||
from hermes_cli.mcp_config import cmd_mcp_login
|
||||
cmd_mcp_login(_make_args(name="srv"))
|
||||
out = capsys.readouterr().out
|
||||
assert "no URL" in out or "not an OAuth" in out
|
||||
|
||||
|
||||
@@ -93,6 +93,59 @@ class TestCopilotDotPreservation:
|
||||
assert result == expected
|
||||
|
||||
|
||||
# ── Copilot model-name normalization (issue #6879 regression) ──────────
|
||||
|
||||
class TestCopilotModelNormalization:
|
||||
"""Copilot requires bare dot-notation model IDs.
|
||||
|
||||
Regression coverage for issue #6879 and the broken Copilot branch
|
||||
that previously left vendor-prefixed Anthropic IDs (e.g.
|
||||
``anthropic/claude-sonnet-4.6``) and dash-notation Claude IDs (e.g.
|
||||
``claude-sonnet-4-6``) unchanged, causing the Copilot API to reject
|
||||
the request with HTTP 400 "model_not_supported".
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("model,expected", [
|
||||
# Vendor-prefixed Anthropic IDs — prefix must be stripped.
|
||||
("anthropic/claude-opus-4.6", "claude-opus-4.6"),
|
||||
("anthropic/claude-sonnet-4.6", "claude-sonnet-4.6"),
|
||||
("anthropic/claude-sonnet-4.5", "claude-sonnet-4.5"),
|
||||
("anthropic/claude-haiku-4.5", "claude-haiku-4.5"),
|
||||
# Vendor-prefixed OpenAI IDs — prefix must be stripped.
|
||||
("openai/gpt-5.4", "gpt-5.4"),
|
||||
("openai/gpt-4o", "gpt-4o"),
|
||||
("openai/gpt-4o-mini", "gpt-4o-mini"),
|
||||
# Dash-notation Claude IDs — must be converted to dot-notation.
|
||||
("claude-opus-4-6", "claude-opus-4.6"),
|
||||
("claude-sonnet-4-6", "claude-sonnet-4.6"),
|
||||
("claude-sonnet-4-5", "claude-sonnet-4.5"),
|
||||
("claude-haiku-4-5", "claude-haiku-4.5"),
|
||||
# Combined: vendor-prefixed + dash-notation.
|
||||
("anthropic/claude-opus-4-6", "claude-opus-4.6"),
|
||||
("anthropic/claude-sonnet-4-6", "claude-sonnet-4.6"),
|
||||
# Already-canonical inputs pass through unchanged.
|
||||
("claude-sonnet-4.6", "claude-sonnet-4.6"),
|
||||
("gpt-5.4", "gpt-5.4"),
|
||||
("gpt-5-mini", "gpt-5-mini"),
|
||||
])
|
||||
def test_copilot_normalization(self, model, expected):
|
||||
assert normalize_model_for_provider(model, "copilot") == expected
|
||||
|
||||
@pytest.mark.parametrize("model,expected", [
|
||||
("anthropic/claude-sonnet-4.6", "claude-sonnet-4.6"),
|
||||
("claude-sonnet-4-6", "claude-sonnet-4.6"),
|
||||
("claude-opus-4-6", "claude-opus-4.6"),
|
||||
("openai/gpt-5.4", "gpt-5.4"),
|
||||
])
|
||||
def test_copilot_acp_normalization(self, model, expected):
|
||||
"""Copilot ACP shares the same API expectations as HTTP Copilot."""
|
||||
assert normalize_model_for_provider(model, "copilot-acp") == expected
|
||||
|
||||
def test_openai_codex_still_strips_openai_prefix(self):
|
||||
"""Regression: openai-codex must still strip the openai/ prefix."""
|
||||
assert normalize_model_for_provider("openai/gpt-5.4", "openai-codex") == "gpt-5.4"
|
||||
|
||||
|
||||
# ── Aggregator providers (regression) ──────────────────────────────────
|
||||
|
||||
class TestAggregatorProviders:
|
||||
|
||||
62
tests/hermes_cli/test_model_picker_viewport.py
Normal file
62
tests/hermes_cli/test_model_picker_viewport.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Tests for the prompt_toolkit /model picker scroll viewport.
|
||||
|
||||
Regression for: when a provider exposes many models (e.g. Ollama Cloud's
|
||||
36+), the picker rendered every choice into a Window with no max height,
|
||||
clipping the bottom border and any items past the terminal's last row.
|
||||
The viewport helper now caps visible items and slides the offset to keep
|
||||
the cursor on screen.
|
||||
"""
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
_compute = HermesCLI._compute_model_picker_viewport
|
||||
|
||||
|
||||
class TestPickerViewport:
|
||||
def test_short_list_no_scroll(self):
|
||||
offset, visible = _compute(selected=0, scroll_offset=0, n=5, term_rows=30)
|
||||
assert offset == 0
|
||||
assert visible == 5
|
||||
|
||||
def test_long_list_caps_visible_to_chrome_budget(self):
|
||||
# 30 rows minus reserved_below=6 minus panel_chrome=6 → max_visible=18.
|
||||
offset, visible = _compute(selected=0, scroll_offset=0, n=36, term_rows=30)
|
||||
assert visible == 18
|
||||
assert offset == 0
|
||||
|
||||
def test_cursor_past_window_scrolls_down(self):
|
||||
offset, visible = _compute(selected=22, scroll_offset=0, n=36, term_rows=30)
|
||||
assert visible == 18
|
||||
assert 22 in range(offset, offset + visible)
|
||||
|
||||
def test_cursor_above_window_scrolls_up(self):
|
||||
offset, visible = _compute(selected=3, scroll_offset=15, n=36, term_rows=30)
|
||||
assert offset == 3
|
||||
assert 3 in range(offset, offset + visible)
|
||||
|
||||
def test_offset_clamped_to_bottom(self):
|
||||
# Selected on the last item — offset must keep the visible window
|
||||
# full, not walk past the end of the list.
|
||||
offset, visible = _compute(selected=35, scroll_offset=0, n=36, term_rows=30)
|
||||
assert offset + visible == 36
|
||||
assert 35 in range(offset, offset + visible)
|
||||
|
||||
def test_tiny_terminal_uses_minimum_visible(self):
|
||||
# term_rows below the chrome budget falls back to the floor of 3 rows.
|
||||
_, visible = _compute(selected=0, scroll_offset=0, n=20, term_rows=10)
|
||||
assert visible == 3
|
||||
|
||||
def test_offset_recovers_after_stage_switch(self):
|
||||
# When the user backs out of the model stage and re-enters with
|
||||
# selected=0, a stale offset from the previous stage must collapse.
|
||||
offset, visible = _compute(selected=0, scroll_offset=25, n=36, term_rows=30)
|
||||
assert offset == 0
|
||||
assert 0 in range(offset, offset + visible)
|
||||
|
||||
def test_full_navigation_keeps_cursor_visible(self):
|
||||
offset = 0
|
||||
for cursor in list(range(36)) + list(range(35, -1, -1)):
|
||||
offset, visible = _compute(cursor, offset, n=36, term_rows=30)
|
||||
assert cursor in range(offset, offset + visible), (
|
||||
f"cursor={cursor} out of view: offset={offset} visible={visible}"
|
||||
)
|
||||
@@ -173,60 +173,6 @@ class TestMemoryPluginCliDiscovery:
|
||||
# ── Honcho register_cli ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHonchoRegisterCli:
|
||||
def test_builds_subcommand_tree(self):
|
||||
"""register_cli creates the expected subparser tree."""
|
||||
from plugins.memory.honcho.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
register_cli(parser)
|
||||
|
||||
# Verify key subcommands exist by parsing them
|
||||
args = parser.parse_args(["status"])
|
||||
assert args.honcho_command == "status"
|
||||
|
||||
args = parser.parse_args(["peer", "--user", "alice"])
|
||||
assert args.honcho_command == "peer"
|
||||
assert args.user == "alice"
|
||||
|
||||
args = parser.parse_args(["mode", "tools"])
|
||||
assert args.honcho_command == "mode"
|
||||
assert args.mode == "tools"
|
||||
|
||||
args = parser.parse_args(["tokens", "--context", "500"])
|
||||
assert args.honcho_command == "tokens"
|
||||
assert args.context == 500
|
||||
|
||||
args = parser.parse_args(["--target-profile", "coder", "status"])
|
||||
assert args.target_profile == "coder"
|
||||
assert args.honcho_command == "status"
|
||||
|
||||
def test_setup_redirects_to_memory_setup(self):
|
||||
"""hermes honcho setup redirects to memory setup."""
|
||||
from plugins.memory.honcho.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
register_cli(parser)
|
||||
args = parser.parse_args(["setup"])
|
||||
assert args.honcho_command == "setup"
|
||||
|
||||
def test_mode_choices_are_recall_modes(self):
|
||||
"""Mode subcommand uses recall mode choices (hybrid/context/tools)."""
|
||||
from plugins.memory.honcho.cli import register_cli
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
register_cli(parser)
|
||||
|
||||
# Valid recall modes should parse
|
||||
for mode in ("hybrid", "context", "tools"):
|
||||
args = parser.parse_args(["mode", mode])
|
||||
assert args.mode == mode
|
||||
|
||||
# Old memoryMode values should fail
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(["mode", "honcho"])
|
||||
|
||||
|
||||
# ── ProviderCollector no-op ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -644,7 +644,7 @@ class TestPluginCommands:
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"):
|
||||
ctx.register_command("", lambda a: a)
|
||||
assert len(mgr._plugin_commands) == 0
|
||||
assert "empty name" in caplog.text
|
||||
@@ -655,7 +655,7 @@ class TestPluginCommands:
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"):
|
||||
ctx.register_command("help", lambda a: a)
|
||||
assert "help" not in mgr._plugin_commands
|
||||
assert "conflicts" in caplog.text.lower()
|
||||
|
||||
@@ -126,59 +126,6 @@ class TestRepoNameFromUrl:
|
||||
# ── plugins_command dispatch ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPluginsCommandDispatch:
|
||||
"""Verify alias routing in plugins_command()."""
|
||||
|
||||
def _make_args(self, action, **extras):
|
||||
args = MagicMock()
|
||||
args.plugins_action = action
|
||||
for k, v in extras.items():
|
||||
setattr(args, k, v)
|
||||
return args
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_remove")
|
||||
def test_rm_alias(self, mock_remove):
|
||||
args = self._make_args("rm", name="some-plugin")
|
||||
plugins_command(args)
|
||||
mock_remove.assert_called_once_with("some-plugin")
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_remove")
|
||||
def test_uninstall_alias(self, mock_remove):
|
||||
args = self._make_args("uninstall", name="some-plugin")
|
||||
plugins_command(args)
|
||||
mock_remove.assert_called_once_with("some-plugin")
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_list")
|
||||
def test_ls_alias(self, mock_list):
|
||||
args = self._make_args("ls")
|
||||
plugins_command(args)
|
||||
mock_list.assert_called_once()
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_toggle")
|
||||
def test_none_falls_through_to_toggle(self, mock_toggle):
|
||||
args = self._make_args(None)
|
||||
plugins_command(args)
|
||||
mock_toggle.assert_called_once()
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_install")
|
||||
def test_install_dispatches(self, mock_install):
|
||||
args = self._make_args("install", identifier="owner/repo", force=False)
|
||||
plugins_command(args)
|
||||
mock_install.assert_called_once_with("owner/repo", force=False)
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_update")
|
||||
def test_update_dispatches(self, mock_update):
|
||||
args = self._make_args("update", name="foo")
|
||||
plugins_command(args)
|
||||
mock_update.assert_called_once_with("foo")
|
||||
|
||||
@patch("hermes_cli.plugins_cmd.cmd_remove")
|
||||
def test_remove_dispatches(self, mock_remove):
|
||||
args = self._make_args("remove", name="bar")
|
||||
plugins_command(args)
|
||||
mock_remove.assert_called_once_with("bar")
|
||||
|
||||
|
||||
# ── _read_manifest ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from hermes_cli import setup as setup_mod
|
||||
|
||||
|
||||
def test_prompt_choice_uses_curses_helper(monkeypatch):
|
||||
monkeypatch.setattr(setup_mod, "_curses_prompt_choice", lambda question, choices, default=0: 1)
|
||||
monkeypatch.setattr(setup_mod, "_curses_prompt_choice", lambda question, choices, default=0, description=None: 1)
|
||||
|
||||
idx = setup_mod.prompt_choice("Pick one", ["a", "b", "c"], default=0)
|
||||
|
||||
@@ -10,7 +10,7 @@ def test_prompt_choice_uses_curses_helper(monkeypatch):
|
||||
|
||||
|
||||
def test_prompt_choice_falls_back_to_numbered_input(monkeypatch):
|
||||
monkeypatch.setattr(setup_mod, "_curses_prompt_choice", lambda question, choices, default=0: -1)
|
||||
monkeypatch.setattr(setup_mod, "_curses_prompt_choice", lambda question, choices, default=0, description=None: -1)
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": "2")
|
||||
|
||||
idx = setup_mod.prompt_choice("Pick one", ["a", "b", "c"], default=0)
|
||||
|
||||
@@ -64,85 +64,3 @@ def _safe_parse(parser, subparsers, argv):
|
||||
subparsers.required = False
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
class TestSubparserRoutingFallback:
|
||||
"""Verify the bpo-9338 defensive routing works for all key cases."""
|
||||
|
||||
def test_direct_subcommand(self):
|
||||
parser, sub = _build_parser()
|
||||
args = _safe_parse(parser, sub, ["model"])
|
||||
assert args.command == "model"
|
||||
|
||||
def test_subcommand_with_flags(self):
|
||||
parser, sub = _build_parser()
|
||||
args = _safe_parse(parser, sub, ["--yolo", "model"])
|
||||
assert args.command == "model"
|
||||
assert args.yolo is True
|
||||
|
||||
def test_bare_hermes_defaults_to_none(self):
|
||||
parser, sub = _build_parser()
|
||||
args = _safe_parse(parser, sub, [])
|
||||
assert args.command is None
|
||||
|
||||
def test_flags_only_defaults_to_none(self):
|
||||
parser, sub = _build_parser()
|
||||
args = _safe_parse(parser, sub, ["--yolo"])
|
||||
assert args.command is None
|
||||
assert args.yolo is True
|
||||
|
||||
def test_continue_flag_alone(self):
|
||||
parser, sub = _build_parser()
|
||||
args = _safe_parse(parser, sub, ["-c"])
|
||||
assert args.command is None
|
||||
assert args.continue_last is True
|
||||
|
||||
def test_continue_with_session_name(self):
|
||||
parser, sub = _build_parser()
|
||||
args = _safe_parse(parser, sub, ["-c", "myproject"])
|
||||
assert args.command is None
|
||||
assert args.continue_last == "myproject"
|
||||
|
||||
def test_continue_with_subcommand_name_as_session(self):
|
||||
"""Edge case: session named 'model' — should be treated as session name, not subcommand."""
|
||||
parser, sub = _build_parser()
|
||||
args = _safe_parse(parser, sub, ["-c", "model"])
|
||||
assert args.command is None
|
||||
assert args.continue_last == "model"
|
||||
|
||||
def test_continue_with_session_then_subcommand(self):
|
||||
parser, sub = _build_parser()
|
||||
args = _safe_parse(parser, sub, ["-c", "myproject", "model"])
|
||||
assert args.command == "model"
|
||||
assert args.continue_last == "myproject"
|
||||
|
||||
def test_chat_with_query(self):
|
||||
parser, sub = _build_parser()
|
||||
args = _safe_parse(parser, sub, ["chat", "-q", "hello"])
|
||||
assert args.command == "chat"
|
||||
assert args.query == "hello"
|
||||
|
||||
def test_resume_flag(self):
|
||||
parser, sub = _build_parser()
|
||||
args = _safe_parse(parser, sub, ["-r", "abc123"])
|
||||
assert args.command is None
|
||||
assert args.resume == "abc123"
|
||||
|
||||
def test_resume_with_subcommand(self):
|
||||
parser, sub = _build_parser()
|
||||
args = _safe_parse(parser, sub, ["-r", "abc123", "chat"])
|
||||
assert args.command == "chat"
|
||||
assert args.resume == "abc123"
|
||||
|
||||
def test_skills_flag_with_subcommand(self):
|
||||
parser, sub = _build_parser()
|
||||
args = _safe_parse(parser, sub, ["-s", "myskill", "chat"])
|
||||
assert args.command == "chat"
|
||||
assert args.skills == ["myskill"]
|
||||
|
||||
def test_all_flags_with_subcommand(self):
|
||||
parser, sub = _build_parser()
|
||||
args = _safe_parse(parser, sub, ["--yolo", "-w", "-s", "myskill", "model"])
|
||||
assert args.command == "model"
|
||||
assert args.yolo is True
|
||||
assert args.worktree is True
|
||||
assert args.skills == ["myskill"]
|
||||
|
||||
@@ -40,6 +40,19 @@ def test_get_platform_tools_preserves_explicit_empty_selection():
|
||||
assert enabled == set()
|
||||
|
||||
|
||||
def test_get_platform_tools_handles_null_platform_toolsets():
|
||||
"""YAML `platform_toolsets:` with no value parses as None — the old
|
||||
``config.get("platform_toolsets", {})`` pattern would then crash with
|
||||
``NoneType has no attribute 'get'`` on the next line. Guard against that.
|
||||
"""
|
||||
config = {"platform_toolsets": None}
|
||||
|
||||
enabled = _get_platform_tools(config, "cli")
|
||||
|
||||
# Falls through to defaults instead of raising
|
||||
assert enabled
|
||||
|
||||
|
||||
def test_platform_toolset_summary_uses_explicit_platform_list():
|
||||
config = {}
|
||||
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
"""Tests for Xiaomi MiMo provider support."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure dotenv doesn't interfere
|
||||
if "dotenv" not in sys.modules:
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
sys.modules["dotenv"] = fake_dotenv
|
||||
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
resolve_provider,
|
||||
|
||||
Reference in New Issue
Block a user