mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
* fix(tests): mock is_safe_url in tests that use example.com Tests using example.com URLs were failing because is_safe_url does a real DNS lookup which fails in environments where example.com doesn't resolve, causing the request to be blocked before reaching the already-mocked HTTP client. This should fix around 17 failing tests. These tests test logic, caching, etc. so mocking this method should not modify them in any way. TestMattermostSendUrlAsFile was already doing this so we follow the same pattern. * fix(test): use case-insensitive lookup for model context length check DEFAULT_CONTEXT_LENGTHS uses inconsistent casing (MiniMax keys are lowercase, Qwen keys are mixed-case) so the test was broken in some cases since it couldn't find the model. * fix(test): patch is_linux in systemd gateway restart test The test only patched is_macos to False but didn't patch is_linux to True. On macOS hosts, is_linux() returns False and the systemd restart code path is skipped entirely, making the assertion fail. * fix(test): use non-blocklisted env var in docker forward_env tests GITHUB_TOKEN is in api_key_env_vars and thus in _HERMES_PROVIDER_ENV_BLOCKLIST so the env var is silently dropped, we replace it with a non-blocked one like DATABASE_URL so the tests actually work. * fix(test): fully isolate _has_any_provider_configured from host env _has_any_provider_configured() checks all env vars from PROVIDER_REGISTRY (not just the 5 the tests were clearing) and also calls get_auth_status() which detects gh auth token for Copilot. On machines with any of these set, the function returns True before reaching the code path under test. Clear all registry vars and mock get_auth_status so host credentials don't interfere. * fix(test): correct path to hermes_base_env.py in tool parser tests Path(__file__).parent.parent resolved to tests/, not the project root. The file lives at environments/hermes_base_env.py so we need one more parent level. * fix(test): accept optional HTML fields in Matrix send payload _send_matrix sometimes adds format and formatted_body when the markdown library is installed. The test was doing an exact dict equality check which broke. Check required fields instead. * fix(test): add config.yaml to codex vision requirements test The test only wrote auth.json but not config.yaml, so _read_main_provider() returned empty and vision auto-detect never tried the codex provider. Add a config.yaml pointing at openai-codex so the fallback path actually resolves the client. * fix(test): clear OPENROUTER_API_KEY in _isolate_hermes_home run_agent.py calls load_hermes_dotenv() at import time, which injects API keys from ~/.hermes/.env into os.environ before any test fixture runs. This caused test_agent_loop_tool_calling to make real API calls instead of skipping, which ends up making some tests fail. * fix(test): add get_rate_limit_state to agent mock in usage report tests _show_usage now calls agent.get_rate_limit_state() for rate limit display. The SimpleNamespace mock was missing this method. * fix(test): update expected Camofox config version from 12 to 13 * fix(test): mock _get_enabled_platforms in nous managed defaults test Importing gateway.run leaks DISCORD_BOT_TOKEN into os.environ, which makes _get_enabled_platforms() return ["cli", "discord"] instead of just ["cli"]. tools_command loops per platform, so apply_nous_managed_defaults runs twice: the first call sets config values, the second sees them as already configured and returns an empty set, causing the assertion to fail.
257 lines
12 KiB
Python
257 lines
12 KiB
Python
"""Tests for Tavily web backend integration.
|
|
|
|
Coverage:
|
|
_tavily_request() — API key handling, endpoint construction, error propagation.
|
|
_normalize_tavily_search_results() — search response normalization.
|
|
_normalize_tavily_documents() — extract/crawl response normalization, failed_results.
|
|
web_search_tool / web_extract_tool / web_crawl_tool — Tavily dispatch paths.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import asyncio
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
|
|
# ─── _tavily_request ─────────────────────────────────────────────────────────
|
|
|
|
class TestTavilyRequest:
|
|
"""Test suite for the _tavily_request helper."""
|
|
|
|
def test_raises_without_api_key(self):
|
|
"""No TAVILY_API_KEY → ValueError with guidance."""
|
|
with patch.dict(os.environ, {}, clear=False):
|
|
os.environ.pop("TAVILY_API_KEY", None)
|
|
from tools.web_tools import _tavily_request
|
|
with pytest.raises(ValueError, match="TAVILY_API_KEY"):
|
|
_tavily_request("search", {"query": "test"})
|
|
|
|
def test_posts_with_api_key_in_body(self):
|
|
"""api_key is injected into the JSON payload."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"results": []}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test-key"}):
|
|
with patch("tools.web_tools.httpx.post", return_value=mock_response) as mock_post:
|
|
from tools.web_tools import _tavily_request
|
|
result = _tavily_request("search", {"query": "hello"})
|
|
|
|
mock_post.assert_called_once()
|
|
call_kwargs = mock_post.call_args
|
|
payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
|
|
assert payload["api_key"] == "tvly-test-key"
|
|
assert payload["query"] == "hello"
|
|
assert "api.tavily.com/search" in call_kwargs.args[0]
|
|
|
|
def test_raises_on_http_error(self):
|
|
"""Non-2xx responses propagate as httpx.HTTPStatusError."""
|
|
import httpx as _httpx
|
|
mock_response = MagicMock()
|
|
mock_response.raise_for_status.side_effect = _httpx.HTTPStatusError(
|
|
"401 Unauthorized", request=MagicMock(), response=mock_response
|
|
)
|
|
|
|
with patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-bad-key"}):
|
|
with patch("tools.web_tools.httpx.post", return_value=mock_response):
|
|
from tools.web_tools import _tavily_request
|
|
with pytest.raises(_httpx.HTTPStatusError):
|
|
_tavily_request("search", {"query": "test"})
|
|
|
|
|
|
# ─── _normalize_tavily_search_results ─────────────────────────────────────────
|
|
|
|
class TestNormalizeTavilySearchResults:
|
|
"""Test search result normalization."""
|
|
|
|
def test_basic_normalization(self):
|
|
from tools.web_tools import _normalize_tavily_search_results
|
|
raw = {
|
|
"results": [
|
|
{"title": "Python Docs", "url": "https://docs.python.org", "content": "Official docs", "score": 0.9},
|
|
{"title": "Tutorial", "url": "https://example.com", "content": "A tutorial", "score": 0.8},
|
|
]
|
|
}
|
|
result = _normalize_tavily_search_results(raw)
|
|
assert result["success"] is True
|
|
web = result["data"]["web"]
|
|
assert len(web) == 2
|
|
assert web[0]["title"] == "Python Docs"
|
|
assert web[0]["url"] == "https://docs.python.org"
|
|
assert web[0]["description"] == "Official docs"
|
|
assert web[0]["position"] == 1
|
|
assert web[1]["position"] == 2
|
|
|
|
def test_empty_results(self):
|
|
from tools.web_tools import _normalize_tavily_search_results
|
|
result = _normalize_tavily_search_results({"results": []})
|
|
assert result["success"] is True
|
|
assert result["data"]["web"] == []
|
|
|
|
def test_missing_fields(self):
|
|
from tools.web_tools import _normalize_tavily_search_results
|
|
result = _normalize_tavily_search_results({"results": [{}]})
|
|
web = result["data"]["web"]
|
|
assert web[0]["title"] == ""
|
|
assert web[0]["url"] == ""
|
|
assert web[0]["description"] == ""
|
|
|
|
|
|
# ─── _normalize_tavily_documents ──────────────────────────────────────────────
|
|
|
|
class TestNormalizeTavilyDocuments:
|
|
"""Test extract/crawl document normalization."""
|
|
|
|
def test_basic_document(self):
|
|
from tools.web_tools import _normalize_tavily_documents
|
|
raw = {
|
|
"results": [{
|
|
"url": "https://example.com",
|
|
"title": "Example",
|
|
"raw_content": "Full page content here",
|
|
}]
|
|
}
|
|
docs = _normalize_tavily_documents(raw)
|
|
assert len(docs) == 1
|
|
assert docs[0]["url"] == "https://example.com"
|
|
assert docs[0]["title"] == "Example"
|
|
assert docs[0]["content"] == "Full page content here"
|
|
assert docs[0]["raw_content"] == "Full page content here"
|
|
assert docs[0]["metadata"]["sourceURL"] == "https://example.com"
|
|
|
|
def test_falls_back_to_content_when_no_raw_content(self):
|
|
from tools.web_tools import _normalize_tavily_documents
|
|
raw = {"results": [{"url": "https://example.com", "content": "Snippet"}]}
|
|
docs = _normalize_tavily_documents(raw)
|
|
assert docs[0]["content"] == "Snippet"
|
|
|
|
def test_failed_results_included(self):
|
|
from tools.web_tools import _normalize_tavily_documents
|
|
raw = {
|
|
"results": [],
|
|
"failed_results": [
|
|
{"url": "https://fail.com", "error": "timeout"},
|
|
],
|
|
}
|
|
docs = _normalize_tavily_documents(raw)
|
|
assert len(docs) == 1
|
|
assert docs[0]["url"] == "https://fail.com"
|
|
assert docs[0]["error"] == "timeout"
|
|
assert docs[0]["content"] == ""
|
|
|
|
def test_failed_urls_included(self):
|
|
from tools.web_tools import _normalize_tavily_documents
|
|
raw = {
|
|
"results": [],
|
|
"failed_urls": ["https://bad.com"],
|
|
}
|
|
docs = _normalize_tavily_documents(raw)
|
|
assert len(docs) == 1
|
|
assert docs[0]["url"] == "https://bad.com"
|
|
assert docs[0]["error"] == "extraction failed"
|
|
|
|
def test_fallback_url(self):
|
|
from tools.web_tools import _normalize_tavily_documents
|
|
raw = {"results": [{"content": "data"}]}
|
|
docs = _normalize_tavily_documents(raw, fallback_url="https://fallback.com")
|
|
assert docs[0]["url"] == "https://fallback.com"
|
|
|
|
|
|
# ─── web_search_tool (Tavily dispatch) ────────────────────────────────────────
|
|
|
|
class TestWebSearchTavily:
|
|
"""Test web_search_tool dispatch to Tavily."""
|
|
|
|
def test_search_dispatches_to_tavily(self):
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"results": [{"title": "Result", "url": "https://r.com", "content": "desc", "score": 0.9}]
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("tools.web_tools._get_backend", return_value="tavily"), \
|
|
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \
|
|
patch("tools.web_tools.httpx.post", return_value=mock_response), \
|
|
patch("tools.interrupt.is_interrupted", return_value=False):
|
|
from tools.web_tools import web_search_tool
|
|
result = json.loads(web_search_tool("test query", limit=3))
|
|
assert result["success"] is True
|
|
assert len(result["data"]["web"]) == 1
|
|
assert result["data"]["web"][0]["title"] == "Result"
|
|
|
|
|
|
# ─── web_extract_tool (Tavily dispatch) ───────────────────────────────────────
|
|
|
|
class TestWebExtractTavily:
|
|
"""Test web_extract_tool dispatch to Tavily."""
|
|
|
|
def test_extract_dispatches_to_tavily(self):
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"results": [{"url": "https://example.com", "raw_content": "Extracted content", "title": "Page"}]
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("tools.web_tools._get_backend", return_value="tavily"), \
|
|
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \
|
|
patch("tools.web_tools.httpx.post", return_value=mock_response), \
|
|
patch("tools.web_tools.process_content_with_llm", return_value=None):
|
|
from tools.web_tools import web_extract_tool
|
|
result = json.loads(asyncio.get_event_loop().run_until_complete(
|
|
web_extract_tool(["https://example.com"], use_llm_processing=False)
|
|
))
|
|
assert "results" in result
|
|
assert len(result["results"]) == 1
|
|
assert result["results"][0]["url"] == "https://example.com"
|
|
|
|
|
|
# ─── web_crawl_tool (Tavily dispatch) ─────────────────────────────────────────
|
|
|
|
class TestWebCrawlTavily:
|
|
"""Test web_crawl_tool dispatch to Tavily."""
|
|
|
|
def test_crawl_dispatches_to_tavily(self):
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {
|
|
"results": [
|
|
{"url": "https://example.com/page1", "raw_content": "Page 1 content", "title": "Page 1"},
|
|
{"url": "https://example.com/page2", "raw_content": "Page 2 content", "title": "Page 2"},
|
|
]
|
|
}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("tools.web_tools._get_backend", return_value="tavily"), \
|
|
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \
|
|
patch("tools.web_tools.httpx.post", return_value=mock_response), \
|
|
patch("tools.web_tools.check_website_access", return_value=None), \
|
|
patch("tools.web_tools.is_safe_url", return_value=True), \
|
|
patch("tools.interrupt.is_interrupted", return_value=False):
|
|
from tools.web_tools import web_crawl_tool
|
|
result = json.loads(asyncio.get_event_loop().run_until_complete(
|
|
web_crawl_tool("https://example.com", use_llm_processing=False)
|
|
))
|
|
assert "results" in result
|
|
assert len(result["results"]) == 2
|
|
assert result["results"][0]["title"] == "Page 1"
|
|
|
|
def test_crawl_sends_instructions(self):
|
|
"""Instructions are included in the Tavily crawl payload."""
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"results": []}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
with patch("tools.web_tools._get_backend", return_value="tavily"), \
|
|
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \
|
|
patch("tools.web_tools.httpx.post", return_value=mock_response) as mock_post, \
|
|
patch("tools.web_tools.check_website_access", return_value=None), \
|
|
patch("tools.web_tools.is_safe_url", return_value=True), \
|
|
patch("tools.interrupt.is_interrupted", return_value=False):
|
|
from tools.web_tools import web_crawl_tool
|
|
asyncio.get_event_loop().run_until_complete(
|
|
web_crawl_tool("https://example.com", instructions="Find docs", use_llm_processing=False)
|
|
)
|
|
call_kwargs = mock_post.call_args
|
|
payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
|
|
assert payload["instructions"] == "Find docs"
|
|
assert payload["url"] == "https://example.com"
|