mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-12 15:09:47 +00:00
* feat(community): add Sofya web search provider Add a community provider backed by Sofya (https://sofya.co). Its search endpoint returns the content of the result pages, not only their snippets, and its fetch endpoint returns a page as markdown. Both are plain JSON over HTTP, so this needs no extra Python package (uses httpx, already a dependency). Changes: - backend/packages/harness/deerflow/community/sofya/__init__.py - backend/packages/harness/deerflow/community/sofya/tools.py Implements web_search_tool and web_fetch_tool using httpx. API key is read from the config.yaml `api_key` field or the SOFYA_API_KEY env var. Follows the same interface and output shape as the existing ddg_search and serper providers, including the max_results parameter with config override and the structured "No results found" error. - backend/tests/test_sofya_tools.py Unit tests covering API key resolution, config overrides, result mapping, time range, HTTP errors, empty results, and fetch failures. - config.example.yaml: add commented-out Sofya web_search and web_fetch examples alongside the other providers - .env.example: add SOFYA_API_KEY placeholder - backend/docs/CONFIGURATION.md: list Sofya under web_search, web_fetch and the environment variables * fix(sofya): honor caller max_results, validate search_depth, join time_range contract test - Caller-supplied max_results now wins; config is used only when the argument is omitted, matching GroundRoute. - search_depth is clamped to basic/snippets; an unsupported value logs a warning and falls back to basic. - Sofya added to the shared time_range schema contract test. * fix(sofya): cap per-result content so a search stays inline An unbounded search payload (up to 20 read pages) crossed the tool output budget middleware's externalize_min_chars threshold, which replaces the result list with a file reference. Cap each result's content at contents_max_characters (default 2000, 0 disables), matching Exa's config key. Five capped results stay under the 12000 char threshold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TZhyPNCX2GYyBPkvTgJV5 * fix(sofya): list Sofya in the recency contract, coerce non-string content _clip subscripted its input, so a non-string content or description from the API raised TypeError instead of degrading. Coerce to text first, the way _sofya_post and _response_results guard the shapes around it. Also add Sofya to the Web Search Recency section in backend/AGENTS.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TZhyPNCX2GYyBPkvTgJV5 * fix(sofya): coerce web_fetch content, list sofya in the tools guide, add changelog web_fetch sliced its content the same way web_search did before the last push: a truthy non-string from the API passed the falsiness guard and then raised TypeError. Reuse _clip, keeping the `or ""` so empty content still reports "No content found". Also add sofya to the community provider inventory in packages/harness/deerflow/tools/AGENTS.md and an [Unreleased] changelog entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TZhyPNCX2GYyBPkvTgJV5 * docs(zh): add the missing InfoQuest and Firecrawl web_fetch tabs The ZH web_fetch tab list named five providers where EN names seven. Both tabs mirror their EN counterparts, so the two locales list the same web_fetch providers again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TZhyPNCX2GYyBPkvTgJV5 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
594 lines
26 KiB
Python
594 lines
26 KiB
Python
"""Unit tests for the Sofya community web search and fetch tools."""
|
|
|
|
import json
|
|
import logging
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_api_key_warned():
|
|
"""Reset the module-level warning flag before each test."""
|
|
import deerflow.community.sofya.tools as sofya_mod
|
|
|
|
sofya_mod._api_key_warned = set()
|
|
yield
|
|
sofya_mod._api_key_warned = set()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_config_with_key():
|
|
with patch("deerflow.community.sofya.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {"api_key": "test-sofya-key", "max_results": 5}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_config_no_key():
|
|
with patch("deerflow.community.sofya.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
yield mock
|
|
|
|
|
|
def _make_response(payload: object) -> MagicMock:
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = payload
|
|
mock_resp.raise_for_status = MagicMock()
|
|
return mock_resp
|
|
|
|
|
|
def _make_search_response(results: list) -> MagicMock:
|
|
return _make_response({"query": "test", "results": results})
|
|
|
|
|
|
def _make_fetch_response(results: list) -> MagicMock:
|
|
return _make_response({"results": results})
|
|
|
|
|
|
class TestGetApiKey:
|
|
def test_returns_config_key_when_present(self):
|
|
with patch("deerflow.community.sofya.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {"api_key": "from-config"}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
|
|
from deerflow.community.sofya.tools import _get_api_key
|
|
|
|
assert _get_api_key("web_search") == "from-config"
|
|
|
|
def test_falls_back_to_env_when_config_key_whitespace(self):
|
|
with patch("deerflow.community.sofya.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {"api_key": " "}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
with patch.dict("os.environ", {"SOFYA_API_KEY": "env-key"}):
|
|
from deerflow.community.sofya.tools import _get_api_key
|
|
|
|
assert _get_api_key("web_search") == "env-key"
|
|
|
|
def test_uses_env_when_tool_is_not_configured(self):
|
|
with patch("deerflow.community.sofya.tools.get_app_config") as mock:
|
|
mock.return_value.get_tool_config.return_value = None
|
|
with patch.dict("os.environ", {"SOFYA_API_KEY": "env-only"}):
|
|
from deerflow.community.sofya.tools import _get_api_key
|
|
|
|
assert _get_api_key("web_fetch") == "env-only"
|
|
|
|
def test_returns_none_when_no_key_anywhere(self):
|
|
with patch("deerflow.community.sofya.tools.get_app_config") as mock:
|
|
mock.return_value.get_tool_config.return_value = None
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
from deerflow.community.sofya.tools import _get_api_key
|
|
|
|
assert _get_api_key("web_search") is None
|
|
|
|
def test_returns_none_when_env_key_whitespace(self):
|
|
with patch("deerflow.community.sofya.tools.get_app_config") as mock:
|
|
mock.return_value.get_tool_config.return_value = None
|
|
with patch.dict("os.environ", {"SOFYA_API_KEY": " "}):
|
|
from deerflow.community.sofya.tools import _get_api_key
|
|
|
|
assert _get_api_key("web_search") is None
|
|
|
|
def test_reads_config_for_requested_tool_name(self):
|
|
with patch("deerflow.community.sofya.tools.get_app_config") as mock:
|
|
tool_config = MagicMock()
|
|
tool_config.model_extra = {"api_key": "fetch-key"}
|
|
mock.return_value.get_tool_config.return_value = tool_config
|
|
|
|
from deerflow.community.sofya.tools import _get_api_key
|
|
|
|
assert _get_api_key("web_fetch") == "fetch-key"
|
|
mock.return_value.get_tool_config.assert_called_with("web_fetch")
|
|
|
|
|
|
class TestCoerceMaxResults:
|
|
def test_returns_value_when_valid_positive_int(self):
|
|
from deerflow.community.sofya.tools import _coerce_max_results
|
|
|
|
assert _coerce_max_results(3) == 3
|
|
|
|
def test_returns_value_for_numeric_string(self):
|
|
from deerflow.community.sofya.tools import _coerce_max_results
|
|
|
|
assert _coerce_max_results("7") == 7
|
|
|
|
def test_caps_value_at_default_maximum(self):
|
|
from deerflow.community.sofya.tools import _coerce_max_results
|
|
|
|
assert _coerce_max_results(999) == 20
|
|
|
|
def test_returns_default_for_non_numeric_string(self):
|
|
from deerflow.community.sofya.tools import _coerce_max_results
|
|
|
|
assert _coerce_max_results("oops") == 5
|
|
|
|
def test_returns_default_for_none(self):
|
|
from deerflow.community.sofya.tools import _coerce_max_results
|
|
|
|
assert _coerce_max_results(None) == 5
|
|
|
|
def test_returns_default_for_zero_or_negative(self):
|
|
from deerflow.community.sofya.tools import _coerce_max_results
|
|
|
|
assert _coerce_max_results(0) == 5
|
|
assert _coerce_max_results(-3) == 5
|
|
|
|
|
|
class TestMissingKeyMessage:
|
|
def test_warns_once_per_tool_name(self, caplog):
|
|
import deerflow.community.sofya.tools as sofya_mod
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
sofya_mod._missing_key_message("web_search")
|
|
sofya_mod._missing_key_message("web_search")
|
|
|
|
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
|
assert len(warnings) == 1
|
|
assert "web_search" in warnings[0].getMessage()
|
|
|
|
def test_warns_separately_for_each_tool(self, caplog):
|
|
import deerflow.community.sofya.tools as sofya_mod
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
sofya_mod._missing_key_message("web_search")
|
|
sofya_mod._missing_key_message("web_fetch")
|
|
|
|
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
|
assert len(warnings) == 2
|
|
|
|
|
|
class TestWebSearchTool:
|
|
def test_basic_search_returns_normalized_results(self, mock_config_with_key):
|
|
results = [
|
|
{"title": "Result 1", "url": "https://example.com/1", "content": "Page content 1", "description": "Snippet 1"},
|
|
{"title": "Result 2", "url": "https://example.com/2", "content": "Page content 2", "description": "Snippet 2"},
|
|
]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_post = mock_client_cls.return_value.__enter__.return_value.post
|
|
mock_post.return_value = _make_search_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "python tutorial"}))
|
|
|
|
assert parsed["query"] == "python tutorial"
|
|
assert parsed["total_results"] == 2
|
|
assert parsed["results"][0] == {"title": "Result 1", "url": "https://example.com/1", "content": "Page content 1"}
|
|
assert mock_post.call_args.kwargs["headers"]["Authorization"] == "Bearer test-sofya-key"
|
|
assert mock_post.call_args.kwargs["json"]["query"] == "python tutorial"
|
|
assert mock_post.call_args.kwargs["json"]["search_depth"] == "basic"
|
|
assert "freshness" not in mock_post.call_args.kwargs["json"]
|
|
|
|
def test_falls_back_to_description_when_content_is_empty(self, mock_config_with_key):
|
|
results = [{"title": "Result", "url": "https://example.com", "content": "", "description": "Snippet"}]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_search_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "test"}))
|
|
|
|
assert parsed["results"][0]["content"] == "Snippet"
|
|
|
|
def test_time_range_is_sent_as_freshness(self, mock_config_with_key):
|
|
results = [{"title": "Result", "url": "https://example.com", "content": "Body"}]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_post = mock_client_cls.return_value.__enter__.return_value.post
|
|
mock_post.return_value = _make_search_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
web_search_tool.invoke({"query": "test", "time_range": "week"})
|
|
|
|
assert mock_post.call_args.kwargs["json"]["freshness"] == "week"
|
|
|
|
def test_search_depth_can_be_set_from_config(self, mock_config_with_key):
|
|
mock_config_with_key.return_value.get_tool_config.return_value.model_extra = {
|
|
"api_key": "test-key",
|
|
"search_depth": "snippets",
|
|
}
|
|
results = [{"title": "Result", "url": "https://example.com", "description": "Snippet"}]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_post = mock_client_cls.return_value.__enter__.return_value.post
|
|
mock_post.return_value = _make_search_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
web_search_tool.invoke({"query": "test"})
|
|
|
|
assert mock_post.call_args.kwargs["json"]["search_depth"] == "snippets"
|
|
|
|
def test_non_string_content_does_not_raise(self, mock_config_with_key):
|
|
results = [
|
|
{"title": "Numeric", "url": "https://example.com/1", "content": 12345},
|
|
{"title": "Listy", "url": "https://example.com/2", "content": None, "description": ["a", "b"]},
|
|
]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_search_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "test"}))
|
|
|
|
assert parsed["results"][0]["content"] == "12345"
|
|
assert parsed["results"][1]["content"] == "['a', 'b']"
|
|
|
|
def test_result_content_is_capped_by_default(self, mock_config_with_key):
|
|
results = [{"title": "Result", "url": "https://example.com", "content": "x" * 9000}]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_search_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "test"}))
|
|
|
|
assert len(parsed["results"][0]["content"]) == 2000
|
|
|
|
def test_contents_max_characters_from_config(self, mock_config_with_key):
|
|
mock_config_with_key.return_value.get_tool_config.return_value.model_extra = {
|
|
"api_key": "test-key",
|
|
"contents_max_characters": 100,
|
|
}
|
|
results = [{"title": "Result", "url": "https://example.com", "content": "x" * 9000}]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_search_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "test"}))
|
|
|
|
assert len(parsed["results"][0]["content"]) == 100
|
|
|
|
def test_contents_max_characters_zero_disables_the_cap(self, mock_config_with_key):
|
|
mock_config_with_key.return_value.get_tool_config.return_value.model_extra = {
|
|
"api_key": "test-key",
|
|
"contents_max_characters": 0,
|
|
}
|
|
results = [{"title": "Result", "url": "https://example.com", "content": "x" * 9000}]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_search_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "test"}))
|
|
|
|
assert len(parsed["results"][0]["content"]) == 9000
|
|
|
|
def test_invalid_contents_max_characters_falls_back_to_default(self):
|
|
from deerflow.community.sofya.tools import _coerce_content_limit
|
|
|
|
assert _coerce_content_limit("oops") == 2000
|
|
assert _coerce_content_limit(None) == 2000
|
|
assert _coerce_content_limit(-5) == 2000
|
|
assert _coerce_content_limit(0) == 0
|
|
assert _coerce_content_limit("150") == 150
|
|
|
|
def test_default_search_stays_under_the_externalize_threshold(self, mock_config_with_key):
|
|
"""Five capped results must stay inline rather than being persisted to disk."""
|
|
results = [{"title": f"R{i}", "url": f"https://example.com/{i}", "content": "x" * 20000} for i in range(5)]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_search_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
output = web_search_tool.invoke({"query": "test"})
|
|
|
|
assert len(output) < 12000
|
|
|
|
def test_caller_max_results_wins_over_config(self, mock_config_with_key):
|
|
mock_config_with_key.return_value.get_tool_config.return_value.model_extra = {
|
|
"api_key": "test-key",
|
|
"max_results": 5,
|
|
}
|
|
results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "content": f"C{i}"} for i in range(10)]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_post = mock_client_cls.return_value.__enter__.return_value.post
|
|
mock_post.return_value = _make_search_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "test", "max_results": 8}))
|
|
|
|
assert parsed["total_results"] == 8
|
|
assert mock_post.call_args.kwargs["json"]["max_results"] == 8
|
|
|
|
def test_unsupported_search_depth_falls_back_with_warning(self, mock_config_with_key, caplog):
|
|
mock_config_with_key.return_value.get_tool_config.return_value.model_extra = {
|
|
"api_key": "test-key",
|
|
"search_depth": "advanced",
|
|
}
|
|
results = [{"title": "Result", "url": "https://example.com", "content": "Body"}]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_post = mock_client_cls.return_value.__enter__.return_value.post
|
|
mock_post.return_value = _make_search_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
web_search_tool.invoke({"query": "test"})
|
|
|
|
assert mock_post.call_args.kwargs["json"]["search_depth"] == "basic"
|
|
assert any("search_depth" in r.getMessage() for r in caplog.records if r.levelno == logging.WARNING)
|
|
|
|
def test_search_depth_is_normalized(self, mock_config_with_key):
|
|
mock_config_with_key.return_value.get_tool_config.return_value.model_extra = {
|
|
"api_key": "test-key",
|
|
"search_depth": " Snippets ",
|
|
}
|
|
results = [{"title": "Result", "url": "https://example.com", "description": "Snippet"}]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_post = mock_client_cls.return_value.__enter__.return_value.post
|
|
mock_post.return_value = _make_search_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
web_search_tool.invoke({"query": "test"})
|
|
|
|
assert mock_post.call_args.kwargs["json"]["search_depth"] == "snippets"
|
|
|
|
def test_respects_max_results_from_config(self, mock_config_with_key):
|
|
mock_config_with_key.return_value.get_tool_config.return_value.model_extra = {
|
|
"api_key": "test-key",
|
|
"max_results": 3,
|
|
}
|
|
results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "content": f"C{i}"} for i in range(10)]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_post = mock_client_cls.return_value.__enter__.return_value.post
|
|
mock_post.return_value = _make_search_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "test"}))
|
|
|
|
assert parsed["total_results"] == 3
|
|
assert mock_post.call_args.kwargs["json"]["max_results"] == 3
|
|
|
|
def test_config_max_results_is_capped(self, mock_config_with_key):
|
|
mock_config_with_key.return_value.get_tool_config.return_value.model_extra = {
|
|
"api_key": "test-key",
|
|
"max_results": 999,
|
|
}
|
|
results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "content": f"C{i}"} for i in range(30)]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_post = mock_client_cls.return_value.__enter__.return_value.post
|
|
mock_post.return_value = _make_search_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "test"}))
|
|
|
|
assert parsed["total_results"] == 20
|
|
assert mock_post.call_args.kwargs["json"]["max_results"] == 20
|
|
|
|
def test_max_results_parameter_accepted(self, mock_config_no_key):
|
|
"""Tool accepts max_results as a call parameter when config does not override it."""
|
|
results = [{"title": f"R{i}", "url": f"https://x.com/{i}", "content": f"C{i}"} for i in range(10)]
|
|
|
|
with patch.dict("os.environ", {"SOFYA_API_KEY": "env-key"}):
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_search_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "test", "max_results": 2}))
|
|
|
|
assert parsed["total_results"] == 2
|
|
|
|
def test_empty_results_return_error_json(self, mock_config_with_key):
|
|
"""An empty result list returns a structured error, matching ddg_search convention."""
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_search_response([])
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "no results"}))
|
|
|
|
assert parsed["error"] == "No results found"
|
|
assert parsed["query"] == "no results"
|
|
|
|
def test_unexpected_results_type_returns_error_json(self, mock_config_with_key):
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_response({"results": "nope"})
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "test"}))
|
|
|
|
assert parsed["error"] == "Sofya returned an unexpected response format"
|
|
|
|
def test_http_error_returns_error_json(self, mock_config_with_key):
|
|
request = httpx.Request("POST", "https://sofya.co/v1/search")
|
|
response = httpx.Response(402, text="Insufficient credits", request=request)
|
|
mock_resp = MagicMock()
|
|
mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError("error", request=request, response=response)
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = mock_resp
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "test"}))
|
|
|
|
assert parsed["error"] == "Sofya API error: HTTP 402"
|
|
|
|
def test_network_error_returns_error_json(self, mock_config_with_key):
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.side_effect = httpx.ConnectError("boom")
|
|
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "test"}))
|
|
|
|
assert parsed["error"] == "boom"
|
|
|
|
def test_missing_key_returns_error_json(self, mock_config_no_key):
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
from deerflow.community.sofya.tools import web_search_tool
|
|
|
|
parsed = json.loads(web_search_tool.invoke({"query": "test"}))
|
|
|
|
assert parsed["error"] == "SOFYA_API_KEY is not configured"
|
|
assert parsed["query"] == "test"
|
|
|
|
|
|
class TestWebFetchTool:
|
|
def test_returns_title_and_content(self, mock_config_with_key):
|
|
results = [{"title": "Example Page", "url": "https://example.com", "content": "# Markdown body", "success": True}]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_post = mock_client_cls.return_value.__enter__.return_value.post
|
|
mock_post.return_value = _make_fetch_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_fetch_tool
|
|
|
|
result = web_fetch_tool.invoke({"url": "https://example.com"})
|
|
|
|
assert result == "# Example Page\n\n# Markdown body"
|
|
assert mock_post.call_args.kwargs["json"] == {"urls": ["https://example.com"]}
|
|
|
|
def test_truncates_long_content(self, mock_config_with_key):
|
|
results = [{"title": "Long", "url": "https://example.com", "content": "x" * 9000, "success": True}]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_fetch_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_fetch_tool
|
|
|
|
result = web_fetch_tool.invoke({"url": "https://example.com"})
|
|
|
|
assert len(result) == len("# Long\n\n") + 4096
|
|
|
|
def test_non_string_content_does_not_raise(self, mock_config_with_key):
|
|
results = [{"title": "Numeric", "url": "https://example.com", "content": 12345, "success": True}]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_fetch_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_fetch_tool
|
|
|
|
result = web_fetch_tool.invoke({"url": "https://example.com"})
|
|
|
|
assert result == "# Numeric\n\n12345"
|
|
|
|
def test_missing_content_still_reports_no_content(self, mock_config_with_key):
|
|
results = [{"title": "Empty", "url": "https://example.com", "content": None, "success": True}]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_fetch_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_fetch_tool
|
|
|
|
result = web_fetch_tool.invoke({"url": "https://example.com"})
|
|
|
|
assert result == "Error: No content found"
|
|
|
|
def test_falls_back_to_untitled(self, mock_config_with_key):
|
|
results = [{"title": "", "url": "https://example.com", "content": "Body", "success": True}]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_fetch_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_fetch_tool
|
|
|
|
result = web_fetch_tool.invoke({"url": "https://example.com"})
|
|
|
|
assert result == "# Untitled\n\nBody"
|
|
|
|
def test_failed_result_returns_its_error(self, mock_config_with_key):
|
|
results = [{"url": "https://example.com", "success": False, "error": "404 Not Found"}]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_fetch_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_fetch_tool
|
|
|
|
result = web_fetch_tool.invoke({"url": "https://example.com"})
|
|
|
|
assert result == "Error: 404 Not Found"
|
|
|
|
def test_empty_content_returns_error(self, mock_config_with_key):
|
|
results = [{"title": "Empty", "url": "https://example.com", "content": "", "success": True}]
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_fetch_response(results)
|
|
|
|
from deerflow.community.sofya.tools import web_fetch_tool
|
|
|
|
result = web_fetch_tool.invoke({"url": "https://example.com"})
|
|
|
|
assert result == "Error: No content found"
|
|
|
|
def test_no_results_returns_error(self, mock_config_with_key):
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = _make_fetch_response([])
|
|
|
|
from deerflow.community.sofya.tools import web_fetch_tool
|
|
|
|
result = web_fetch_tool.invoke({"url": "https://example.com"})
|
|
|
|
assert result == "Error: No results found"
|
|
|
|
def test_http_error_returns_error_string(self, mock_config_with_key):
|
|
request = httpx.Request("POST", "https://sofya.co/v1/fetch")
|
|
response = httpx.Response(401, text="Invalid API key", request=request)
|
|
mock_resp = MagicMock()
|
|
mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError("error", request=request, response=response)
|
|
|
|
with patch("deerflow.community.sofya.tools.httpx.Client") as mock_client_cls:
|
|
mock_client_cls.return_value.__enter__.return_value.post.return_value = mock_resp
|
|
|
|
from deerflow.community.sofya.tools import web_fetch_tool
|
|
|
|
result = web_fetch_tool.invoke({"url": "https://example.com"})
|
|
|
|
assert result == "Error: Sofya API error: HTTP 401"
|
|
|
|
def test_missing_key_returns_error_string(self, mock_config_no_key):
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
from deerflow.community.sofya.tools import web_fetch_tool
|
|
|
|
result = web_fetch_tool.invoke({"url": "https://example.com"})
|
|
|
|
assert result == "Error: SOFYA_API_KEY is not configured"
|