diff --git a/tests/tools/test_tool_backend_helpers.py b/tests/tools/test_tool_backend_helpers.py new file mode 100644 index 0000000000..faaed9c5e0 --- /dev/null +++ b/tests/tools/test_tool_backend_helpers.py @@ -0,0 +1,287 @@ +"""Unit tests for tools/tool_backend_helpers.py. + +Tests cover: +- managed_nous_tools_enabled() feature flag +- normalize_browser_cloud_provider() coercion +- coerce_modal_mode() / normalize_modal_mode() validation +- has_direct_modal_credentials() detection +- resolve_modal_backend_state() backend selection matrix +- resolve_openai_audio_api_key() priority chain +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from tools.tool_backend_helpers import ( + coerce_modal_mode, + has_direct_modal_credentials, + managed_nous_tools_enabled, + normalize_browser_cloud_provider, + normalize_modal_mode, + resolve_modal_backend_state, + resolve_openai_audio_api_key, +) + + +# --------------------------------------------------------------------------- +# managed_nous_tools_enabled +# --------------------------------------------------------------------------- +class TestManagedNousToolsEnabled: + """Feature flag driven by HERMES_ENABLE_NOUS_MANAGED_TOOLS.""" + + def test_disabled_by_default(self, monkeypatch): + monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False) + assert managed_nous_tools_enabled() is False + + @pytest.mark.parametrize("val", ["1", "true", "True", "yes"]) + def test_enabled_when_truthy(self, monkeypatch, val): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", val) + assert managed_nous_tools_enabled() is True + + @pytest.mark.parametrize("val", ["0", "false", "no", ""]) + def test_disabled_when_falsy(self, monkeypatch, val): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", val) + assert managed_nous_tools_enabled() is False + + +# --------------------------------------------------------------------------- +# normalize_browser_cloud_provider +# --------------------------------------------------------------------------- +class TestNormalizeBrowserCloudProvider: + """Coerce arbitrary input to a lowercase browser provider key.""" + + def test_none_returns_default(self): + assert normalize_browser_cloud_provider(None) == "local" + + def test_empty_string_returns_default(self): + assert normalize_browser_cloud_provider("") == "local" + + def test_whitespace_only_returns_default(self): + assert normalize_browser_cloud_provider(" ") == "local" + + def test_known_provider_normalized(self): + assert normalize_browser_cloud_provider("BrowserBase") == "browserbase" + + def test_strips_whitespace(self): + assert normalize_browser_cloud_provider(" Local ") == "local" + + def test_integer_coerced(self): + result = normalize_browser_cloud_provider(42) + assert isinstance(result, str) + assert result == "42" + + +# --------------------------------------------------------------------------- +# coerce_modal_mode / normalize_modal_mode +# --------------------------------------------------------------------------- +class TestCoerceModalMode: + """Validate and coerce the requested modal execution mode.""" + + @pytest.mark.parametrize("value", ["auto", "direct", "managed"]) + def test_valid_modes_passthrough(self, value): + assert coerce_modal_mode(value) == value + + def test_none_returns_auto(self): + assert coerce_modal_mode(None) == "auto" + + def test_empty_string_returns_auto(self): + assert coerce_modal_mode("") == "auto" + + def test_whitespace_only_returns_auto(self): + assert coerce_modal_mode(" ") == "auto" + + def test_uppercase_normalized(self): + assert coerce_modal_mode("DIRECT") == "direct" + + def test_mixed_case_normalized(self): + assert coerce_modal_mode("Managed") == "managed" + + def test_invalid_mode_falls_back_to_auto(self): + assert coerce_modal_mode("invalid") == "auto" + assert coerce_modal_mode("cloud") == "auto" + + def test_strips_whitespace(self): + assert coerce_modal_mode(" managed ") == "managed" + + +class TestNormalizeModalMode: + """normalize_modal_mode is an alias for coerce_modal_mode.""" + + def test_delegates_to_coerce(self): + assert normalize_modal_mode("direct") == coerce_modal_mode("direct") + assert normalize_modal_mode(None) == coerce_modal_mode(None) + assert normalize_modal_mode("bogus") == coerce_modal_mode("bogus") + + +# --------------------------------------------------------------------------- +# has_direct_modal_credentials +# --------------------------------------------------------------------------- +class TestHasDirectModalCredentials: + """Detect Modal credentials via env vars or config file.""" + + def test_no_env_no_file(self, monkeypatch, tmp_path): + monkeypatch.delenv("MODAL_TOKEN_ID", raising=False) + monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False) + with patch.object(Path, "home", return_value=tmp_path): + assert has_direct_modal_credentials() is False + + def test_both_env_vars_set(self, monkeypatch, tmp_path): + monkeypatch.setenv("MODAL_TOKEN_ID", "id-123") + monkeypatch.setenv("MODAL_TOKEN_SECRET", "sec-456") + with patch.object(Path, "home", return_value=tmp_path): + assert has_direct_modal_credentials() is True + + def test_only_token_id_not_enough(self, monkeypatch, tmp_path): + monkeypatch.setenv("MODAL_TOKEN_ID", "id-123") + monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False) + with patch.object(Path, "home", return_value=tmp_path): + assert has_direct_modal_credentials() is False + + def test_only_token_secret_not_enough(self, monkeypatch, tmp_path): + monkeypatch.delenv("MODAL_TOKEN_ID", raising=False) + monkeypatch.setenv("MODAL_TOKEN_SECRET", "sec-456") + with patch.object(Path, "home", return_value=tmp_path): + assert has_direct_modal_credentials() is False + + def test_config_file_present(self, monkeypatch, tmp_path): + monkeypatch.delenv("MODAL_TOKEN_ID", raising=False) + monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False) + (tmp_path / ".modal.toml").touch() + with patch.object(Path, "home", return_value=tmp_path): + assert has_direct_modal_credentials() is True + + def test_env_vars_take_priority_over_file(self, monkeypatch, tmp_path): + monkeypatch.setenv("MODAL_TOKEN_ID", "id-123") + monkeypatch.setenv("MODAL_TOKEN_SECRET", "sec-456") + (tmp_path / ".modal.toml").touch() + with patch.object(Path, "home", return_value=tmp_path): + assert has_direct_modal_credentials() is True + + +# --------------------------------------------------------------------------- +# resolve_modal_backend_state +# --------------------------------------------------------------------------- +class TestResolveModalBackendState: + """Full matrix of direct vs managed Modal backend selection.""" + + @staticmethod + def _resolve(monkeypatch, mode, *, has_direct, managed_ready, nous_enabled=False): + """Helper to call resolve_modal_backend_state with feature flag control.""" + if nous_enabled: + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + else: + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "") + return resolve_modal_backend_state( + mode, has_direct=has_direct, managed_ready=managed_ready + ) + + # --- auto mode --- + + def test_auto_prefers_managed_when_available(self, monkeypatch): + result = self._resolve(monkeypatch, "auto", has_direct=True, managed_ready=True, nous_enabled=True) + assert result["selected_backend"] == "managed" + + def test_auto_falls_back_to_direct(self, monkeypatch): + result = self._resolve(monkeypatch, "auto", has_direct=True, managed_ready=False, nous_enabled=True) + assert result["selected_backend"] == "direct" + + def test_auto_no_backends_available(self, monkeypatch): + result = self._resolve(monkeypatch, "auto", has_direct=False, managed_ready=False) + assert result["selected_backend"] is None + + def test_auto_managed_ready_but_nous_disabled(self, monkeypatch): + result = self._resolve(monkeypatch, "auto", has_direct=True, managed_ready=True, nous_enabled=False) + assert result["selected_backend"] == "direct" + + def test_auto_nothing_when_only_managed_and_nous_disabled(self, monkeypatch): + result = self._resolve(monkeypatch, "auto", has_direct=False, managed_ready=True, nous_enabled=False) + assert result["selected_backend"] is None + + # --- direct mode --- + + def test_direct_selects_direct_when_available(self, monkeypatch): + result = self._resolve(monkeypatch, "direct", has_direct=True, managed_ready=True, nous_enabled=True) + assert result["selected_backend"] == "direct" + + def test_direct_none_when_no_credentials(self, monkeypatch): + result = self._resolve(monkeypatch, "direct", has_direct=False, managed_ready=True, nous_enabled=True) + assert result["selected_backend"] is None + + # --- managed mode --- + + def test_managed_selects_managed_when_ready_and_enabled(self, monkeypatch): + result = self._resolve(monkeypatch, "managed", has_direct=True, managed_ready=True, nous_enabled=True) + assert result["selected_backend"] == "managed" + + def test_managed_none_when_not_ready(self, monkeypatch): + result = self._resolve(monkeypatch, "managed", has_direct=True, managed_ready=False, nous_enabled=True) + assert result["selected_backend"] is None + + def test_managed_blocked_when_nous_disabled(self, monkeypatch): + result = self._resolve(monkeypatch, "managed", has_direct=True, managed_ready=True, nous_enabled=False) + assert result["selected_backend"] is None + assert result["managed_mode_blocked"] is True + + # --- return structure --- + + def test_return_dict_keys(self, monkeypatch): + result = self._resolve(monkeypatch, "auto", has_direct=True, managed_ready=False) + expected_keys = { + "requested_mode", + "mode", + "has_direct", + "managed_ready", + "managed_mode_blocked", + "selected_backend", + } + assert set(result.keys()) == expected_keys + + def test_passthrough_flags(self, monkeypatch): + result = self._resolve(monkeypatch, "direct", has_direct=True, managed_ready=False) + assert result["requested_mode"] == "direct" + assert result["mode"] == "direct" + assert result["has_direct"] is True + assert result["managed_ready"] is False + + # --- invalid mode falls back to auto --- + + def test_invalid_mode_treated_as_auto(self, monkeypatch): + result = self._resolve(monkeypatch, "bogus", has_direct=True, managed_ready=False) + assert result["requested_mode"] == "auto" + assert result["mode"] == "auto" + + +# --------------------------------------------------------------------------- +# resolve_openai_audio_api_key +# --------------------------------------------------------------------------- +class TestResolveOpenaiAudioApiKey: + """Priority: VOICE_TOOLS_OPENAI_KEY > OPENAI_API_KEY.""" + + def test_voice_key_preferred(self, monkeypatch): + monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "voice-key") + monkeypatch.setenv("OPENAI_API_KEY", "general-key") + assert resolve_openai_audio_api_key() == "voice-key" + + def test_falls_back_to_openai_key(self, monkeypatch): + monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "general-key") + assert resolve_openai_audio_api_key() == "general-key" + + def test_empty_voice_key_falls_back(self, monkeypatch): + monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "") + monkeypatch.setenv("OPENAI_API_KEY", "general-key") + assert resolve_openai_audio_api_key() == "general-key" + + def test_no_keys_returns_empty(self, monkeypatch): + monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + assert resolve_openai_audio_api_key() == "" + + def test_strips_whitespace(self, monkeypatch): + monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", " voice-key ") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + assert resolve_openai_audio_api_key() == "voice-key"