Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
1232
tests/agent/test_bedrock_adapter.py
Normal file
1232
tests/agent/test_bedrock_adapter.py
Normal file
File diff suppressed because it is too large
Load Diff
269
tests/agent/test_bedrock_integration.py
Normal file
269
tests/agent/test_bedrock_integration.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""Integration tests for the AWS Bedrock provider wiring.
|
||||
|
||||
Verifies that the Bedrock provider is correctly registered in the
|
||||
provider registry, model catalog, and runtime resolution pipeline.
|
||||
These tests do NOT require AWS credentials or boto3 — all AWS calls
|
||||
are mocked.
|
||||
|
||||
Note: Tests that import ``hermes_cli.auth`` or ``hermes_cli.runtime_provider``
|
||||
require Python 3.10+ due to ``str | None`` type syntax in the import chain.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestProviderRegistry:
|
||||
"""Verify Bedrock is registered in PROVIDER_REGISTRY."""
|
||||
|
||||
def test_bedrock_in_registry(self):
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
assert "bedrock" in PROVIDER_REGISTRY
|
||||
|
||||
def test_bedrock_auth_type_is_aws_sdk(self):
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
pconfig = PROVIDER_REGISTRY["bedrock"]
|
||||
assert pconfig.auth_type == "aws_sdk"
|
||||
|
||||
def test_bedrock_has_no_api_key_env_vars(self):
|
||||
"""Bedrock uses the AWS SDK credential chain, not API keys."""
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
pconfig = PROVIDER_REGISTRY["bedrock"]
|
||||
assert pconfig.api_key_env_vars == ()
|
||||
|
||||
def test_bedrock_base_url_env_var(self):
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
pconfig = PROVIDER_REGISTRY["bedrock"]
|
||||
assert pconfig.base_url_env_var == "BEDROCK_BASE_URL"
|
||||
|
||||
|
||||
class TestProviderAliases:
|
||||
"""Verify Bedrock aliases resolve correctly."""
|
||||
|
||||
def test_aws_alias(self):
|
||||
from hermes_cli.models import _PROVIDER_ALIASES
|
||||
assert _PROVIDER_ALIASES.get("aws") == "bedrock"
|
||||
|
||||
def test_aws_bedrock_alias(self):
|
||||
from hermes_cli.models import _PROVIDER_ALIASES
|
||||
assert _PROVIDER_ALIASES.get("aws-bedrock") == "bedrock"
|
||||
|
||||
def test_amazon_bedrock_alias(self):
|
||||
from hermes_cli.models import _PROVIDER_ALIASES
|
||||
assert _PROVIDER_ALIASES.get("amazon-bedrock") == "bedrock"
|
||||
|
||||
def test_amazon_alias(self):
|
||||
from hermes_cli.models import _PROVIDER_ALIASES
|
||||
assert _PROVIDER_ALIASES.get("amazon") == "bedrock"
|
||||
|
||||
|
||||
class TestProviderLabels:
|
||||
"""Verify Bedrock appears in provider labels."""
|
||||
|
||||
def test_bedrock_label(self):
|
||||
from hermes_cli.models import _PROVIDER_LABELS
|
||||
assert _PROVIDER_LABELS.get("bedrock") == "AWS Bedrock"
|
||||
|
||||
|
||||
class TestModelCatalog:
|
||||
"""Verify Bedrock has a static model fallback list."""
|
||||
|
||||
def test_bedrock_has_curated_models(self):
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
models = _PROVIDER_MODELS.get("bedrock", [])
|
||||
assert len(models) > 0
|
||||
|
||||
def test_bedrock_models_include_claude(self):
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
models = _PROVIDER_MODELS.get("bedrock", [])
|
||||
claude_models = [m for m in models if "anthropic.claude" in m]
|
||||
assert len(claude_models) > 0
|
||||
|
||||
def test_bedrock_models_include_nova(self):
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
models = _PROVIDER_MODELS.get("bedrock", [])
|
||||
nova_models = [m for m in models if "amazon.nova" in m]
|
||||
assert len(nova_models) > 0
|
||||
|
||||
|
||||
class TestResolveProvider:
|
||||
"""Verify resolve_provider() handles bedrock correctly."""
|
||||
|
||||
def test_explicit_bedrock_resolves(self, monkeypatch):
|
||||
"""When user explicitly requests 'bedrock', it should resolve."""
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
# bedrock is in the registry, so resolve_provider should return it
|
||||
from hermes_cli.auth import resolve_provider
|
||||
result = resolve_provider("bedrock")
|
||||
assert result == "bedrock"
|
||||
|
||||
def test_aws_alias_resolves_to_bedrock(self):
|
||||
from hermes_cli.auth import resolve_provider
|
||||
result = resolve_provider("aws")
|
||||
assert result == "bedrock"
|
||||
|
||||
def test_amazon_bedrock_alias_resolves(self):
|
||||
from hermes_cli.auth import resolve_provider
|
||||
result = resolve_provider("amazon-bedrock")
|
||||
assert result == "bedrock"
|
||||
|
||||
def test_auto_detect_with_aws_credentials(self, monkeypatch):
|
||||
"""When AWS credentials are present and no other provider is configured,
|
||||
auto-detect should find bedrock."""
|
||||
from hermes_cli.auth import resolve_provider
|
||||
|
||||
# Clear all other provider env vars
|
||||
for var in ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "GOOGLE_API_KEY", "DEEPSEEK_API_KEY"]:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
# Set AWS credentials
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
|
||||
|
||||
# Mock the auth store to have no active provider
|
||||
with patch("hermes_cli.auth._load_auth_store", return_value={}):
|
||||
result = resolve_provider("auto")
|
||||
assert result == "bedrock"
|
||||
|
||||
|
||||
class TestRuntimeProvider:
|
||||
"""Verify resolve_runtime_provider() handles bedrock correctly."""
|
||||
|
||||
def test_bedrock_runtime_resolution(self, monkeypatch):
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
|
||||
monkeypatch.setenv("AWS_REGION", "eu-west-1")
|
||||
|
||||
# Mock resolve_provider to return bedrock
|
||||
with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \
|
||||
patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}):
|
||||
result = resolve_runtime_provider(requested="bedrock")
|
||||
|
||||
assert result["provider"] == "bedrock"
|
||||
assert result["api_mode"] == "bedrock_converse"
|
||||
assert result["region"] == "eu-west-1"
|
||||
assert "bedrock-runtime.eu-west-1.amazonaws.com" in result["base_url"]
|
||||
assert result["api_key"] == "aws-sdk"
|
||||
|
||||
def test_bedrock_runtime_default_region(self, monkeypatch):
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
monkeypatch.setenv("AWS_PROFILE", "default")
|
||||
monkeypatch.delenv("AWS_REGION", raising=False)
|
||||
monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False)
|
||||
|
||||
with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \
|
||||
patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}):
|
||||
result = resolve_runtime_provider(requested="bedrock")
|
||||
|
||||
assert result["region"] == "us-east-1"
|
||||
|
||||
def test_bedrock_runtime_no_credentials_raises_on_auto_detect(self, monkeypatch):
|
||||
"""When bedrock is auto-detected (not explicitly requested) and no
|
||||
credentials are found, runtime resolution should raise AuthError."""
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
from hermes_cli.auth import AuthError
|
||||
|
||||
# Clear all AWS env vars
|
||||
for var in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_PROFILE",
|
||||
"AWS_BEARER_TOKEN_BEDROCK", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
|
||||
"AWS_WEB_IDENTITY_TOKEN_FILE"]:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
# Mock both the provider resolution and boto3's credential chain
|
||||
mock_session = MagicMock()
|
||||
mock_session.get_credentials.return_value = None
|
||||
with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \
|
||||
patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}), \
|
||||
patch("hermes_cli.runtime_provider.resolve_requested_provider", return_value="auto"), \
|
||||
patch.dict("sys.modules", {"botocore": MagicMock(), "botocore.session": MagicMock()}):
|
||||
import botocore.session as _bs
|
||||
_bs.get_session = MagicMock(return_value=mock_session)
|
||||
with pytest.raises(AuthError, match="No AWS credentials"):
|
||||
resolve_runtime_provider(requested="auto")
|
||||
|
||||
def test_bedrock_runtime_explicit_skips_credential_check(self, monkeypatch):
|
||||
"""When user explicitly requests bedrock, trust boto3's credential chain
|
||||
even if env-var detection finds nothing (covers IMDS, SSO, etc.)."""
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
# No AWS env vars set — but explicit bedrock request should not raise
|
||||
for var in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_PROFILE",
|
||||
"AWS_BEARER_TOKEN_BEDROCK"]:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
with patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \
|
||||
patch("hermes_cli.runtime_provider._get_model_config", return_value={"provider": "bedrock"}):
|
||||
result = resolve_runtime_provider(requested="bedrock")
|
||||
assert result["provider"] == "bedrock"
|
||||
assert result["api_mode"] == "bedrock_converse"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# providers.py integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestProvidersModule:
|
||||
"""Verify bedrock is wired into hermes_cli/providers.py."""
|
||||
|
||||
def test_bedrock_alias_in_providers(self):
|
||||
from hermes_cli.providers import ALIASES
|
||||
assert ALIASES.get("bedrock") is None # "bedrock" IS the canonical name, not an alias
|
||||
assert ALIASES.get("aws") == "bedrock"
|
||||
assert ALIASES.get("aws-bedrock") == "bedrock"
|
||||
|
||||
def test_bedrock_transport_mapping(self):
|
||||
from hermes_cli.providers import TRANSPORT_TO_API_MODE
|
||||
assert TRANSPORT_TO_API_MODE.get("bedrock_converse") == "bedrock_converse"
|
||||
|
||||
def test_determine_api_mode_from_bedrock_url(self):
|
||||
from hermes_cli.providers import determine_api_mode
|
||||
assert determine_api_mode(
|
||||
"unknown", "https://bedrock-runtime.us-east-1.amazonaws.com"
|
||||
) == "bedrock_converse"
|
||||
|
||||
def test_label_override(self):
|
||||
from hermes_cli.providers import _LABEL_OVERRIDES
|
||||
assert _LABEL_OVERRIDES.get("bedrock") == "AWS Bedrock"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error classifier integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestErrorClassifierBedrock:
|
||||
"""Verify Bedrock error patterns are in the global error classifier."""
|
||||
|
||||
def test_throttling_in_rate_limit_patterns(self):
|
||||
from agent.error_classifier import _RATE_LIMIT_PATTERNS
|
||||
assert "throttlingexception" in _RATE_LIMIT_PATTERNS
|
||||
|
||||
def test_context_overflow_patterns(self):
|
||||
from agent.error_classifier import _CONTEXT_OVERFLOW_PATTERNS
|
||||
assert "input is too long" in _CONTEXT_OVERFLOW_PATTERNS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pyproject.toml bedrock extra
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPackaging:
|
||||
"""Verify bedrock optional dependency is declared."""
|
||||
|
||||
def test_bedrock_extra_exists(self):
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
# Read pyproject.toml to verify [bedrock] extra
|
||||
toml_path = Path(__file__).parent.parent.parent / "pyproject.toml"
|
||||
content = toml_path.read_text()
|
||||
assert 'bedrock = ["boto3' in content
|
||||
|
||||
def test_bedrock_in_all_extra(self):
|
||||
from pathlib import Path
|
||||
content = (Path(__file__).parent.parent.parent / "pyproject.toml").read_text()
|
||||
assert '"hermes-agent[bedrock]"' in content
|
||||
253
tests/agent/test_nous_rate_guard.py
Normal file
253
tests/agent/test_nous_rate_guard.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""Tests for agent/nous_rate_guard.py — cross-session Nous Portal rate limit guard."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rate_guard_env(tmp_path, monkeypatch):
|
||||
"""Isolate rate guard state to a temp directory."""
|
||||
hermes_home = str(tmp_path / ".hermes")
|
||||
os.makedirs(hermes_home, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", hermes_home)
|
||||
# Clear any cached module-level imports
|
||||
return hermes_home
|
||||
|
||||
|
||||
class TestRecordNousRateLimit:
|
||||
"""Test recording rate limit state."""
|
||||
|
||||
def test_records_with_header_reset(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, _state_path
|
||||
|
||||
headers = {"x-ratelimit-reset-requests-1h": "1800"}
|
||||
record_nous_rate_limit(headers=headers)
|
||||
|
||||
path = _state_path()
|
||||
assert os.path.exists(path)
|
||||
with open(path) as f:
|
||||
state = json.load(f)
|
||||
assert state["reset_seconds"] == pytest.approx(1800, abs=2)
|
||||
assert state["reset_at"] > time.time()
|
||||
|
||||
def test_records_with_per_minute_header(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, _state_path
|
||||
|
||||
headers = {"x-ratelimit-reset-requests": "45"}
|
||||
record_nous_rate_limit(headers=headers)
|
||||
|
||||
with open(_state_path()) as f:
|
||||
state = json.load(f)
|
||||
assert state["reset_seconds"] == pytest.approx(45, abs=2)
|
||||
|
||||
def test_records_with_retry_after_header(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, _state_path
|
||||
|
||||
headers = {"retry-after": "60"}
|
||||
record_nous_rate_limit(headers=headers)
|
||||
|
||||
with open(_state_path()) as f:
|
||||
state = json.load(f)
|
||||
assert state["reset_seconds"] == pytest.approx(60, abs=2)
|
||||
|
||||
def test_prefers_hourly_over_per_minute(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, _state_path
|
||||
|
||||
headers = {
|
||||
"x-ratelimit-reset-requests-1h": "1800",
|
||||
"x-ratelimit-reset-requests": "45",
|
||||
}
|
||||
record_nous_rate_limit(headers=headers)
|
||||
|
||||
with open(_state_path()) as f:
|
||||
state = json.load(f)
|
||||
# Should use the hourly value, not the per-minute one
|
||||
assert state["reset_seconds"] == pytest.approx(1800, abs=2)
|
||||
|
||||
def test_falls_back_to_error_context_reset_at(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, _state_path
|
||||
|
||||
future_reset = time.time() + 900
|
||||
record_nous_rate_limit(
|
||||
headers=None,
|
||||
error_context={"reset_at": future_reset},
|
||||
)
|
||||
|
||||
with open(_state_path()) as f:
|
||||
state = json.load(f)
|
||||
assert state["reset_at"] == pytest.approx(future_reset, abs=1)
|
||||
|
||||
def test_falls_back_to_default_cooldown(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, _state_path
|
||||
|
||||
record_nous_rate_limit(headers=None)
|
||||
|
||||
with open(_state_path()) as f:
|
||||
state = json.load(f)
|
||||
# Default is 300 seconds (5 minutes)
|
||||
assert state["reset_seconds"] == pytest.approx(300, abs=2)
|
||||
|
||||
def test_custom_default_cooldown(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, _state_path
|
||||
|
||||
record_nous_rate_limit(headers=None, default_cooldown=120.0)
|
||||
|
||||
with open(_state_path()) as f:
|
||||
state = json.load(f)
|
||||
assert state["reset_seconds"] == pytest.approx(120, abs=2)
|
||||
|
||||
def test_creates_directory_if_missing(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, _state_path
|
||||
|
||||
record_nous_rate_limit(headers={"retry-after": "10"})
|
||||
assert os.path.exists(_state_path())
|
||||
|
||||
|
||||
class TestNousRateLimitRemaining:
|
||||
"""Test checking remaining rate limit time."""
|
||||
|
||||
def test_returns_none_when_no_file(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import nous_rate_limit_remaining
|
||||
|
||||
assert nous_rate_limit_remaining() is None
|
||||
|
||||
def test_returns_remaining_seconds_when_active(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit, nous_rate_limit_remaining
|
||||
|
||||
record_nous_rate_limit(headers={"x-ratelimit-reset-requests-1h": "600"})
|
||||
remaining = nous_rate_limit_remaining()
|
||||
assert remaining is not None
|
||||
assert 595 < remaining <= 605 # ~600 seconds, allowing for test execution time
|
||||
|
||||
def test_returns_none_when_expired(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import nous_rate_limit_remaining, _state_path
|
||||
|
||||
# Write an already-expired state
|
||||
state_dir = os.path.dirname(_state_path())
|
||||
os.makedirs(state_dir, exist_ok=True)
|
||||
with open(_state_path(), "w") as f:
|
||||
json.dump({"reset_at": time.time() - 10, "recorded_at": time.time() - 100}, f)
|
||||
|
||||
assert nous_rate_limit_remaining() is None
|
||||
# File should be cleaned up
|
||||
assert not os.path.exists(_state_path())
|
||||
|
||||
def test_handles_corrupt_file(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import nous_rate_limit_remaining, _state_path
|
||||
|
||||
state_dir = os.path.dirname(_state_path())
|
||||
os.makedirs(state_dir, exist_ok=True)
|
||||
with open(_state_path(), "w") as f:
|
||||
f.write("not valid json{{{")
|
||||
|
||||
assert nous_rate_limit_remaining() is None
|
||||
|
||||
|
||||
class TestClearNousRateLimit:
|
||||
"""Test clearing rate limit state."""
|
||||
|
||||
def test_clears_existing_file(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import (
|
||||
record_nous_rate_limit,
|
||||
clear_nous_rate_limit,
|
||||
nous_rate_limit_remaining,
|
||||
_state_path,
|
||||
)
|
||||
|
||||
record_nous_rate_limit(headers={"retry-after": "600"})
|
||||
assert nous_rate_limit_remaining() is not None
|
||||
|
||||
clear_nous_rate_limit()
|
||||
assert nous_rate_limit_remaining() is None
|
||||
assert not os.path.exists(_state_path())
|
||||
|
||||
def test_clear_when_no_file(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import clear_nous_rate_limit
|
||||
|
||||
# Should not raise
|
||||
clear_nous_rate_limit()
|
||||
|
||||
|
||||
class TestFormatRemaining:
|
||||
"""Test human-readable duration formatting."""
|
||||
|
||||
def test_seconds(self):
|
||||
from agent.nous_rate_guard import format_remaining
|
||||
|
||||
assert format_remaining(30) == "30s"
|
||||
|
||||
def test_minutes(self):
|
||||
from agent.nous_rate_guard import format_remaining
|
||||
|
||||
assert format_remaining(125) == "2m 5s"
|
||||
|
||||
def test_exact_minutes(self):
|
||||
from agent.nous_rate_guard import format_remaining
|
||||
|
||||
assert format_remaining(120) == "2m"
|
||||
|
||||
def test_hours(self):
|
||||
from agent.nous_rate_guard import format_remaining
|
||||
|
||||
assert format_remaining(3720) == "1h 2m"
|
||||
|
||||
|
||||
class TestParseResetSeconds:
|
||||
"""Test header parsing for reset times."""
|
||||
|
||||
def test_case_insensitive_headers(self, rate_guard_env):
|
||||
from agent.nous_rate_guard import _parse_reset_seconds
|
||||
|
||||
headers = {"X-Ratelimit-Reset-Requests-1h": "1200"}
|
||||
assert _parse_reset_seconds(headers) == 1200.0
|
||||
|
||||
def test_returns_none_for_empty_headers(self):
|
||||
from agent.nous_rate_guard import _parse_reset_seconds
|
||||
|
||||
assert _parse_reset_seconds(None) is None
|
||||
assert _parse_reset_seconds({}) is None
|
||||
|
||||
def test_ignores_zero_values(self):
|
||||
from agent.nous_rate_guard import _parse_reset_seconds
|
||||
|
||||
headers = {"x-ratelimit-reset-requests-1h": "0"}
|
||||
assert _parse_reset_seconds(headers) is None
|
||||
|
||||
def test_ignores_invalid_values(self):
|
||||
from agent.nous_rate_guard import _parse_reset_seconds
|
||||
|
||||
headers = {"x-ratelimit-reset-requests-1h": "not-a-number"}
|
||||
assert _parse_reset_seconds(headers) is None
|
||||
|
||||
|
||||
class TestAuxiliaryClientIntegration:
|
||||
"""Test that the auxiliary client respects the rate guard."""
|
||||
|
||||
def test_try_nous_skips_when_rate_limited(self, rate_guard_env, monkeypatch):
|
||||
from agent.nous_rate_guard import record_nous_rate_limit
|
||||
|
||||
# Record a rate limit
|
||||
record_nous_rate_limit(headers={"retry-after": "600"})
|
||||
|
||||
# Mock _read_nous_auth to return valid creds (would normally succeed)
|
||||
import agent.auxiliary_client as aux
|
||||
monkeypatch.setattr(aux, "_read_nous_auth", lambda: {
|
||||
"access_token": "test-token",
|
||||
"inference_base_url": "https://api.nous.test/v1",
|
||||
})
|
||||
|
||||
result = aux._try_nous()
|
||||
assert result == (None, None)
|
||||
|
||||
def test_try_nous_works_when_not_rate_limited(self, rate_guard_env, monkeypatch):
|
||||
import agent.auxiliary_client as aux
|
||||
|
||||
# No rate limit recorded — _try_nous should proceed normally
|
||||
# (will return None because no real creds, but won't be blocked
|
||||
# by the rate guard)
|
||||
monkeypatch.setattr(aux, "_read_nous_auth", lambda: None)
|
||||
result = aux._try_nous()
|
||||
assert result == (None, None)
|
||||
60
tests/agent/test_proxy_and_url_validation.py
Normal file
60
tests/agent/test_proxy_and_url_validation.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Tests for malformed proxy env var and base URL validation.
|
||||
|
||||
Salvaged from PR #6403 by MestreY0d4-Uninter — validates that the agent
|
||||
surfaces clear errors instead of cryptic httpx ``Invalid port`` exceptions
|
||||
when proxy env vars or custom endpoint URLs are malformed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.auxiliary_client import _validate_base_url, _validate_proxy_env_urls
|
||||
|
||||
|
||||
# -- proxy env validation ------------------------------------------------
|
||||
|
||||
|
||||
def test_proxy_env_accepts_normal_values(monkeypatch):
|
||||
monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.1:6153")
|
||||
monkeypatch.setenv("HTTPS_PROXY", "https://proxy.example.com:8443")
|
||||
monkeypatch.setenv("ALL_PROXY", "socks5://127.0.0.1:1080")
|
||||
_validate_proxy_env_urls() # should not raise
|
||||
|
||||
|
||||
def test_proxy_env_accepts_empty(monkeypatch):
|
||||
monkeypatch.delenv("HTTP_PROXY", raising=False)
|
||||
monkeypatch.delenv("HTTPS_PROXY", raising=False)
|
||||
monkeypatch.delenv("ALL_PROXY", raising=False)
|
||||
monkeypatch.delenv("http_proxy", raising=False)
|
||||
monkeypatch.delenv("https_proxy", raising=False)
|
||||
monkeypatch.delenv("all_proxy", raising=False)
|
||||
_validate_proxy_env_urls() # should not raise
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", [
|
||||
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
|
||||
"http_proxy", "https_proxy", "all_proxy",
|
||||
])
|
||||
def test_proxy_env_rejects_malformed_port(monkeypatch, key):
|
||||
monkeypatch.setenv(key, "http://127.0.0.1:6153export")
|
||||
with pytest.raises(RuntimeError, match=rf"Malformed proxy environment variable {key}=.*6153export"):
|
||||
_validate_proxy_env_urls()
|
||||
|
||||
|
||||
# -- base URL validation -------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://api.example.com/v1",
|
||||
"http://127.0.0.1:6153/v1",
|
||||
"acp://copilot",
|
||||
"",
|
||||
None,
|
||||
])
|
||||
def test_base_url_accepts_valid(url):
|
||||
_validate_base_url(url) # should not raise
|
||||
|
||||
|
||||
def test_base_url_rejects_malformed_port():
|
||||
with pytest.raises(RuntimeError, match="Malformed custom endpoint URL"):
|
||||
_validate_base_url("http://127.0.0.1:6153export")
|
||||
@@ -284,3 +284,95 @@ class TestElevenLabsTavilyExaKeys:
|
||||
assert "XYZ789abcdef" not in result
|
||||
assert "HOME=/home/user" in result
|
||||
assert "SHELL=/bin/bash" in result
|
||||
|
||||
|
||||
class TestJWTTokens:
|
||||
"""JWT tokens start with eyJ (base64 for '{') and have dot-separated parts."""
|
||||
|
||||
def test_full_3part_jwt(self):
|
||||
text = (
|
||||
"Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
".eyJpc3MiOiI0MjNiZDJkYjg4MjI0MDAwIn0"
|
||||
".Gxgv0rru-_kS-I_60EJ7CENTnBh9UeuL3QhkMoQ-VnM"
|
||||
)
|
||||
result = redact_sensitive_text(text)
|
||||
assert "Token:" in result
|
||||
# Payload and signature must not survive
|
||||
assert "eyJpc3Mi" not in result
|
||||
assert "Gxgv0rru" not in result
|
||||
|
||||
def test_2part_jwt(self):
|
||||
text = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "eyJzdWIi" not in result
|
||||
|
||||
def test_standalone_jwt_header(self):
|
||||
text = "leaked header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 here"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "IkpXVCJ9" not in result
|
||||
assert "leaked header:" in result
|
||||
|
||||
def test_jwt_with_base64_padding(self):
|
||||
text = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=.abc123def456ghij"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "abc123def456" not in result
|
||||
|
||||
def test_short_eyj_not_matched(self):
|
||||
"""eyJ followed by fewer than 10 base64 chars should not match."""
|
||||
text = "eyJust a normal word"
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
def test_jwt_preserves_surrounding_text(self):
|
||||
text = "before eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0 after"
|
||||
result = redact_sensitive_text(text)
|
||||
assert result.startswith("before ")
|
||||
assert result.endswith(" after")
|
||||
|
||||
def test_home_assistant_jwt_in_memory(self):
|
||||
"""Real-world pattern: HA token stored in agent memory block."""
|
||||
text = (
|
||||
"Home Assistant API Token: "
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
".eyJpc3MiOiJhYmNkZWYiLCJleHAiOjE3NzQ5NTcxMDN9"
|
||||
".Gxgv0rru-_kS-I_60EJ7CENTnBh9UeuL3QhkMoQ-VnM"
|
||||
)
|
||||
result = redact_sensitive_text(text)
|
||||
assert "Home Assistant API Token:" in result
|
||||
assert "Gxgv0rru" not in result
|
||||
assert "..." in result
|
||||
|
||||
|
||||
class TestDiscordMentions:
|
||||
"""Discord snowflake IDs in <@ID> or <@!ID> format."""
|
||||
|
||||
def test_normal_mention(self):
|
||||
result = redact_sensitive_text("Hello <@222589316709220353>")
|
||||
assert "222589316709220353" not in result
|
||||
assert "<@***>" in result
|
||||
|
||||
def test_nickname_mention(self):
|
||||
result = redact_sensitive_text("Ping <@!1331549159177846844>")
|
||||
assert "1331549159177846844" not in result
|
||||
assert "<@!***>" in result
|
||||
|
||||
def test_multiple_mentions(self):
|
||||
text = "<@111111111111111111> and <@222222222222222222>"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "111111111111111111" not in result
|
||||
assert "222222222222222222" not in result
|
||||
|
||||
def test_short_id_not_matched(self):
|
||||
"""IDs shorter than 17 digits are not Discord snowflakes."""
|
||||
text = "<@12345>"
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
def test_slack_mention_not_matched(self):
|
||||
"""Slack mentions use letters, not pure digits."""
|
||||
text = "<@U024BE7LH>"
|
||||
assert redact_sensitive_text(text) == text
|
||||
|
||||
def test_preserves_surrounding_text(self):
|
||||
text = "User <@222589316709220353> said hello"
|
||||
result = redact_sensitive_text(text)
|
||||
assert result.startswith("User ")
|
||||
assert result.endswith(" said hello")
|
||||
|
||||
@@ -193,6 +193,67 @@ class TestLoadGatewayConfig:
|
||||
|
||||
assert config.thread_sessions_per_user is False
|
||||
|
||||
def test_bridges_discord_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"discord:\n"
|
||||
" channel_prompts:\n"
|
||||
" \"123\": Research mode\n"
|
||||
" 456: Therapist mode\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.platforms[Platform.DISCORD].extra["channel_prompts"] == {
|
||||
"123": "Research mode",
|
||||
"456": "Therapist mode",
|
||||
}
|
||||
|
||||
def test_bridges_telegram_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"telegram:\n"
|
||||
" channel_prompts:\n"
|
||||
' "-1001234567": Research assistant\n'
|
||||
" 789: Creative writing\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.platforms[Platform.TELEGRAM].extra["channel_prompts"] == {
|
||||
"-1001234567": "Research assistant",
|
||||
"789": "Creative writing",
|
||||
}
|
||||
|
||||
def test_bridges_slack_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"slack:\n"
|
||||
" channel_prompts:\n"
|
||||
' "C01ABC": Code review mode\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.platforms[Platform.SLACK].extra["channel_prompts"] == {
|
||||
"C01ABC": "Code review mode",
|
||||
}
|
||||
|
||||
def test_invalid_quick_commands_in_config_yaml_are_ignored(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
259
tests/gateway/test_discord_channel_prompts.py
Normal file
259
tests/gateway/test_discord_channel_prompts.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""Tests for Discord channel_prompts resolution and injection."""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _ensure_discord_mock():
|
||||
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
|
||||
return
|
||||
discord_mod = types.ModuleType("discord")
|
||||
discord_mod.Intents = MagicMock()
|
||||
discord_mod.Intents.default.return_value = MagicMock()
|
||||
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||
discord_mod.Thread = type("Thread", (), {})
|
||||
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||
discord_mod.Interaction = object
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
commands_mod.Bot = MagicMock
|
||||
ext_mod.commands = commands_mod
|
||||
sys.modules.setdefault("discord", discord_mod)
|
||||
sys.modules.setdefault("discord.ext", ext_mod)
|
||||
sys.modules.setdefault("discord.ext.commands", commands_mod)
|
||||
|
||||
|
||||
import gateway.run as gateway_run
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
class _CapturingAgent:
|
||||
last_init = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
type(self).last_init = dict(kwargs)
|
||||
self.tools = []
|
||||
|
||||
def run_conversation(self, user_message, conversation_history=None, task_id=None, persist_user_message=None):
|
||||
return {
|
||||
"final_response": "ok",
|
||||
"messages": [],
|
||||
"api_calls": 1,
|
||||
"completed": True,
|
||||
}
|
||||
|
||||
|
||||
def _install_fake_agent(monkeypatch):
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = _CapturingAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
_ensure_discord_mock()
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter.config = MagicMock()
|
||||
adapter.config.extra = {}
|
||||
return adapter
|
||||
|
||||
|
||||
def _make_runner():
|
||||
runner = object.__new__(gateway_run.GatewayRunner)
|
||||
runner.adapters = {}
|
||||
runner._ephemeral_system_prompt = "Global prompt"
|
||||
runner._prefill_messages = []
|
||||
runner._reasoning_config = None
|
||||
runner._service_tier = None
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._smart_model_routing = {}
|
||||
runner._running_agents = {}
|
||||
runner._pending_model_notes = {}
|
||||
runner._session_db = None
|
||||
runner._agent_cache = {}
|
||||
runner._agent_cache_lock = threading.Lock()
|
||||
runner._session_model_overrides = {}
|
||||
runner.hooks = SimpleNamespace(loaded_hooks=False)
|
||||
runner.config = SimpleNamespace(streaming=None)
|
||||
runner.session_store = SimpleNamespace(
|
||||
get_or_create_session=lambda source: SimpleNamespace(session_id="session-1"),
|
||||
load_transcript=lambda session_id: [],
|
||||
)
|
||||
runner._get_or_create_gateway_honcho = lambda session_key: (None, None)
|
||||
runner._enrich_message_with_vision = AsyncMock(return_value="ENRICHED")
|
||||
return runner
|
||||
|
||||
|
||||
def _make_source() -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="12345",
|
||||
chat_type="thread",
|
||||
user_id="user-1",
|
||||
)
|
||||
|
||||
|
||||
class TestResolveChannelPrompts:
|
||||
def test_no_prompt_returns_none(self):
|
||||
adapter = _make_adapter()
|
||||
assert adapter._resolve_channel_prompt("123") is None
|
||||
|
||||
def test_match_by_channel_id(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {"channel_prompts": {"100": "Research mode"}}
|
||||
assert adapter._resolve_channel_prompt("100") == "Research mode"
|
||||
|
||||
def test_numeric_yaml_keys_normalized_at_config_load(self):
|
||||
"""Numeric YAML keys are normalized to strings by config bridging.
|
||||
|
||||
The resolver itself expects string keys (config.py handles normalization),
|
||||
so raw numeric keys will not match — this is intentional.
|
||||
"""
|
||||
adapter = _make_adapter()
|
||||
# Simulates post-bridging state: keys are already strings
|
||||
adapter.config.extra = {"channel_prompts": {"100": "Research mode"}}
|
||||
assert adapter._resolve_channel_prompt("100") == "Research mode"
|
||||
# Pre-bridging numeric key would not match (bridging is responsible)
|
||||
adapter.config.extra = {"channel_prompts": {100: "Research mode"}}
|
||||
assert adapter._resolve_channel_prompt("100") is None
|
||||
|
||||
def test_match_by_parent_id(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {"channel_prompts": {"200": "Forum prompt"}}
|
||||
assert adapter._resolve_channel_prompt("999", parent_id="200") == "Forum prompt"
|
||||
|
||||
def test_exact_channel_overrides_parent(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {
|
||||
"channel_prompts": {
|
||||
"999": "Thread override",
|
||||
"200": "Forum prompt",
|
||||
}
|
||||
}
|
||||
assert adapter._resolve_channel_prompt("999", parent_id="200") == "Thread override"
|
||||
|
||||
def test_build_message_event_sets_channel_prompt(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {"channel_prompts": {"321": "Command prompt"}}
|
||||
adapter.build_source = MagicMock(return_value=SimpleNamespace())
|
||||
|
||||
interaction = SimpleNamespace(
|
||||
channel_id=321,
|
||||
channel=SimpleNamespace(name="general", guild=None, parent_id=None),
|
||||
user=SimpleNamespace(id=1, display_name="Brenner"),
|
||||
)
|
||||
adapter._get_effective_topic = MagicMock(return_value=None)
|
||||
|
||||
event = adapter._build_slash_event(interaction, "/retry")
|
||||
|
||||
assert event.channel_prompt == "Command prompt"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_thread_session_inherits_parent_channel_prompt(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {"channel_prompts": {"200": "Parent prompt"}}
|
||||
adapter.build_source = MagicMock(return_value=SimpleNamespace())
|
||||
adapter._get_effective_topic = MagicMock(return_value=None)
|
||||
adapter.handle_message = AsyncMock()
|
||||
|
||||
interaction = SimpleNamespace(
|
||||
guild=SimpleNamespace(name="Wetlands"),
|
||||
channel=SimpleNamespace(id=200, parent=None),
|
||||
user=SimpleNamespace(id=1, display_name="Brenner"),
|
||||
)
|
||||
|
||||
await adapter._dispatch_thread_session(interaction, "999", "new-thread", "hello")
|
||||
|
||||
dispatched_event = adapter.handle_message.await_args.args[0]
|
||||
assert dispatched_event.channel_prompt == "Parent prompt"
|
||||
|
||||
def test_blank_prompts_are_ignored(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {"channel_prompts": {"100": " "}}
|
||||
assert adapter._resolve_channel_prompt("100") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_preserves_channel_prompt(monkeypatch):
|
||||
runner = _make_runner()
|
||||
runner.session_store = SimpleNamespace(
|
||||
get_or_create_session=lambda source: SimpleNamespace(session_id="session-1", last_prompt_tokens=10),
|
||||
load_transcript=lambda session_id: [
|
||||
{"role": "user", "content": "original message"},
|
||||
{"role": "assistant", "content": "old reply"},
|
||||
],
|
||||
rewrite_transcript=MagicMock(),
|
||||
)
|
||||
runner._handle_message = AsyncMock(return_value="ok")
|
||||
|
||||
event = MessageEvent(
|
||||
text="/retry",
|
||||
message_type=gateway_run.MessageType.COMMAND,
|
||||
source=_make_source(),
|
||||
raw_message=SimpleNamespace(),
|
||||
channel_prompt="Channel prompt",
|
||||
)
|
||||
|
||||
result = await runner._handle_retry_command(event)
|
||||
|
||||
assert result == "ok"
|
||||
retried_event = runner._handle_message.await_args.args[0]
|
||||
assert retried_event.channel_prompt == "Channel prompt"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_appends_channel_prompt_to_ephemeral_system_prompt(monkeypatch, tmp_path):
|
||||
_install_fake_agent(monkeypatch)
|
||||
runner = _make_runner()
|
||||
|
||||
(tmp_path / "config.yaml").write_text("agent:\n system_prompt: Global prompt\n", encoding="utf-8")
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_env_path", tmp_path / ".env")
|
||||
monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
|
||||
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4")
|
||||
monkeypatch.setattr(
|
||||
gateway_run,
|
||||
"_resolve_runtime_agent_kwargs",
|
||||
lambda: {
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key": "***",
|
||||
},
|
||||
)
|
||||
|
||||
import hermes_cli.tools_config as tools_config
|
||||
|
||||
monkeypatch.setattr(tools_config, "_get_platform_tools", lambda user_config, platform_key: {"core"})
|
||||
|
||||
_CapturingAgent.last_init = None
|
||||
event = MessageEvent(
|
||||
text="hi",
|
||||
source=_make_source(),
|
||||
message_id="m1",
|
||||
channel_prompt="Channel prompt",
|
||||
)
|
||||
result = await runner._run_agent(
|
||||
message="hi",
|
||||
context_prompt="Context prompt",
|
||||
history=[],
|
||||
source=_make_source(),
|
||||
session_id="session-1",
|
||||
session_key="agent:main:discord:thread:12345",
|
||||
channel_prompt=event.channel_prompt,
|
||||
)
|
||||
|
||||
assert result["final_response"] == "ok"
|
||||
assert _CapturingAgent.last_init["ephemeral_system_prompt"] == (
|
||||
"Context prompt\n\nChannel prompt\n\nGlobal prompt"
|
||||
)
|
||||
@@ -459,7 +459,7 @@ class TestCustomProviderCompatibility:
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert raw["_config_version"] == 17
|
||||
assert raw["_config_version"] == 18
|
||||
assert raw["providers"]["openai-direct"] == {
|
||||
"api": "https://api.openai.com/v1",
|
||||
"api_key": "test-key",
|
||||
@@ -606,6 +606,26 @@ class TestInterimAssistantMessageConfig:
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert raw["_config_version"] == 17
|
||||
assert raw["_config_version"] == 18
|
||||
assert raw["display"]["tool_progress"] == "off"
|
||||
assert raw["display"]["interim_assistant_messages"] is True
|
||||
|
||||
|
||||
class TestDiscordChannelPromptsConfig:
|
||||
def test_default_config_includes_discord_channel_prompts(self):
|
||||
assert DEFAULT_CONFIG["discord"]["channel_prompts"] == {}
|
||||
|
||||
def test_migrate_adds_discord_channel_prompts_default(self, tmp_path):
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(
|
||||
yaml.safe_dump({"_config_version": 17, "discord": {"auto_thread": True}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert raw["_config_version"] == 18
|
||||
assert raw["discord"]["auto_thread"] is True
|
||||
assert raw["discord"]["channel_prompts"] == {}
|
||||
|
||||
@@ -64,4 +64,4 @@ class TestCamofoxConfigDefaults:
|
||||
|
||||
# The current schema version is tracked globally; unrelated default
|
||||
# options may bump it after browser defaults are added.
|
||||
assert DEFAULT_CONFIG["_config_version"] == 17
|
||||
assert DEFAULT_CONFIG["_config_version"] == 18
|
||||
|
||||
Reference in New Issue
Block a user