deer-flow/backend/tests/test_groundroute_response_shapes.py
Jason Cat 8834a759b8
fix(groundroute): contain malformed web search and fetch payloads (#6603)
* fix(groundroute): contain malformed web search and fetch payloads

web_search and web_fetch assumed the GroundRoute 200 body was an object with
a `results` list of objects, and read it outside the request try/except. A
body that arrived as a bare list, as an object with a non-list `results`, or
with non-object entries raised AttributeError / KeyError out of the tool
boundary, so the tool call failed with a stack trace instead of the provider's
format error.

Validate the payload before normalizing it, following the containment rule
already applied to brave (#6550), serper, serply, sofya, tencent_wsa and
unbrowse: a non-object payload, a non-list `results` container, or a non-empty
list holding no objects returns the structured format error, non-object entries
in an otherwise valid list are skipped in order, and missing, null or empty
results keep returning "No results found".

* fix(groundroute): coerce non-string fetch fields

web_fetch sliced the fetched page's `content` (falling back to `snippet`)
to the snippet limit without checking its type, so a 200 body whose result
object held a number, boolean, or object there raised TypeError / KeyError
out of the tool boundary -- the same escape the previous commit closed for
the container shape. A null title also rendered as "# None".

Coerce non-string content to text before truncating, and render a missing
or non-string title as empty, matching the coercion sofya and unbrowse
already apply to their fetch fields.

* docs(groundroute): scope the falsy-title note to actual behavior

The guidance said a non-string title renders as empty, but the code only
empties falsy titles; a truthy non-string title still renders as its text
form via the f-string. Say "falsy" instead.
2026-10-10 16:56:42 +08:00

179 lines
7.7 KiB
Python

"""GroundRoute web_search/web_fetch must contain malformed HTTP-success payloads."""
import json
import logging
from types import SimpleNamespace
import httpx
import pytest
_LOGGER_NAME = "deerflow.community.groundroute.tools"
_FORMAT_ERROR = "GroundRoute returned an unexpected response format"
_SEARCH_QUERY = "文档 search"
_FETCH_URL = "https://example.com/文档"
@pytest.fixture(autouse=True)
def reset_api_key_warned():
import deerflow.community.groundroute.tools as gr_mod
gr_mod._api_key_warned = set()
yield
gr_mod._api_key_warned = set()
def _invoker(monkeypatch, tool_name):
"""Patch the provider's httpx reference with a local MockTransport client factory."""
from deerflow.community.groundroute import tools
config = SimpleNamespace(get_tool_config=lambda _: SimpleNamespace(model_extra={"api_key": "synthetic-test-key"}))
monkeypatch.setattr(tools, "get_app_config", lambda: config)
real_client = httpx.Client
def invoke(payload, *, status_code=200):
requests = []
def respond(request):
requests.append(request)
return httpx.Response(status_code, content=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"})
transport = httpx.MockTransport(respond)
monkeypatch.setattr(
tools,
"httpx",
SimpleNamespace(Client=lambda **kwargs: real_client(transport=transport, **kwargs), HTTPStatusError=httpx.HTTPStatusError),
)
if tool_name == "web_search":
result = tools.web_search_tool.invoke({"query": _SEARCH_QUERY})
else:
result = tools.web_fetch_tool.invoke({"url": _FETCH_URL})
assert len(requests) == 1
return result
return invoke
@pytest.fixture
def invoke_search(monkeypatch):
return _invoker(monkeypatch, "web_search")
@pytest.fixture
def invoke_fetch(monkeypatch):
return _invoker(monkeypatch, "web_fetch")
def _search(invoke_search, payload, *, status_code=200):
return json.loads(invoke_search(payload, status_code=status_code))
def _error_logs(caplog):
return [record.getMessage() for record in caplog.records if record.name == _LOGGER_NAME and record.levelno == logging.ERROR]
def _assert_no_payload_values_leaked(caplog):
assert "文档 search" not in caplog.text
assert "example.com" not in caplog.text
assert "synthetic-test-key" not in caplog.text
assert "provider-private-payload" not in caplog.text
@pytest.mark.parametrize("payload", [None, False, 0, "invalid", [], ["provider-private-payload"]])
def test_malformed_top_level_payload_returns_format_error(invoke_search, payload, caplog):
assert _search(invoke_search, payload) == {"error": _FORMAT_ERROR, "query": "文档 search"}
assert _error_logs(caplog) == [f"GroundRoute returned unexpected payload type: {type(payload).__name__}"]
_assert_no_payload_values_leaked(caplog)
@pytest.mark.parametrize("results", [False, 0, "", "provider-private-payload", {}, {"title": "provider-private-payload"}])
def test_malformed_results_container_returns_format_error(invoke_search, results, caplog):
assert _search(invoke_search, {"results": results}) == {"error": _FORMAT_ERROR, "query": "文档 search"}
assert _error_logs(caplog) == [f"GroundRoute returned unexpected 'results' payload type: {type(results).__name__}"]
_assert_no_payload_values_leaked(caplog)
@pytest.mark.parametrize("results", [[None], [False, 0, "provider-private-payload", []]])
def test_all_malformed_entries_return_format_error(invoke_search, results, caplog):
assert _search(invoke_search, {"results": results}) == {"error": _FORMAT_ERROR, "query": "文档 search"}
assert _error_logs(caplog) == ["GroundRoute returned 'results' with no usable result objects"]
_assert_no_payload_values_leaked(caplog)
def test_mixed_entries_preserve_valid_results_in_order(invoke_search, caplog):
payload = {"results": [None, {"title": "文档", "url": "https://other.example/one", "snippet": "概述", "source_engine": "serper"}, "invalid", {}, [], {"title": "Second"}]}
assert _search(invoke_search, payload) == [
{"title": "文档", "url": "https://other.example/one", "snippet": "概述", "source_engine": "serper"},
{"title": "", "url": "", "snippet": "", "source_engine": ""},
{"title": "Second", "url": "", "snippet": "", "source_engine": ""},
]
assert _error_logs(caplog) == []
@pytest.mark.parametrize("payload", [{}, {"results": None}, {"request_id": "r1"}, {"results": []}])
def test_missing_or_empty_results_keep_no_results_response(invoke_search, payload, caplog):
assert _search(invoke_search, payload) == {"error": "No results found", "query": "文档 search"}
assert _error_logs(caplog) == []
def test_valid_results_keep_existing_normalization(invoke_search, caplog):
payload = {"results": [{"title": "T", "url": "https://other.example", "snippet": "s", "source_engine": "exa"}, {}]}
assert _search(invoke_search, payload) == [
{"title": "T", "url": "https://other.example", "snippet": "s", "source_engine": "exa"},
{"title": "", "url": "", "snippet": "", "source_engine": ""},
]
assert _error_logs(caplog) == []
@pytest.mark.parametrize("payload", [None, False, [], "provider-private-payload", {"results": {"0": "provider-private-payload"}}, {"results": [None]}])
def test_fetch_malformed_payload_returns_format_error(invoke_fetch, payload, caplog):
assert invoke_fetch(payload) == "Error: GroundRoute returned an unexpected response format"
assert len(_error_logs(caplog)) == 1
_assert_no_payload_values_leaked(caplog)
def test_fetch_missing_or_empty_results_keep_no_results_error(invoke_fetch, caplog):
assert invoke_fetch({"results": []}) == "Error: No results found"
assert invoke_fetch({}) == "Error: No results found"
assert _error_logs(caplog) == []
def test_fetch_skips_malformed_entries_and_returns_the_first_usable_result(invoke_fetch):
assert invoke_fetch({"results": [None, {"title": "Page", "content": "Body text", "url": "https://other.example"}]}) == "# Page\n\nBody text"
def test_valid_fetch_keeps_existing_response(invoke_fetch, caplog):
assert invoke_fetch({"results": [{"title": "Page", "content": "Body text"}]}) == "# Page\n\nBody text"
assert _error_logs(caplog) == []
@pytest.mark.parametrize(
("payload", "expected"),
[
({"results": [{"title": "Page", "content": 12345}]}, "# Page\n\n12345"),
({"results": [{"title": "Page", "content": True}]}, "# Page\n\nTrue"),
({"results": [{"title": "Page", "content": {"a": 1}}]}, "# Page\n\n{'a': 1}"),
({"results": [{"title": "Page", "snippet": 6789}]}, "# Page\n\n6789"),
({"results": [{"title": None, "content": "Body text"}]}, "# \n\nBody text"),
],
)
def test_fetch_coerces_non_string_fields(invoke_fetch, payload, expected, caplog):
assert invoke_fetch(payload) == expected
assert _error_logs(caplog) == []
def test_http_transport_patch_does_not_replace_global_client(invoke_search):
real_client = httpx.Client
invoke_search({"results": []})
assert httpx.Client is real_client
with httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(204))) as client:
assert client.get("https://unrelated.invalid/probe").status_code == 204
@pytest.mark.parametrize("status_code", [402, 503])
def test_search_http_error_keeps_existing_structured_response(invoke_search, status_code):
assert _search(invoke_search, {"error": "synthetic-denial"}, status_code=status_code) == {"error": f"GroundRoute API error: HTTP {status_code}", "query": "文档 search"}
def test_fetch_http_error_keeps_existing_error_string(invoke_fetch):
assert invoke_fetch({"error": "synthetic-denial"}, status_code=402) == "Error: GroundRoute API error: HTTP 402"