返回 last30days-skill
test_grounding_v3.py
根目录 / tests / test_grounding_v3.py
1 import io
2 import json
3 import threading
4 import unittest
5 import urllib.error
6 from contextlib import contextmanager
7 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8 from unittest.mock import patch
9
10 from lib import grounding, parallel_mcp
11
12
13 class BraveSearchTests(unittest.TestCase):
14 def test_brave_search_applies_freshness_and_filters_to_in_range_dated_items(self):
15 mock_response = {
16 "web": {
17 "results": [
18 {
19 "title": "Test Article",
20 "url": "https://example.com/article",
21 "description": "A test snippet",
22 "page_age": "2026-03-10T00:00:00",
23 },
24 {
25 "title": "Old Article",
26 "url": "https://example.com/old",
27 "description": "Should be filtered",
28 "page_age": "2025-12-10T00:00:00",
29 },
30 {
31 "title": "Undated Article",
32 "url": "https://example.com/undated",
33 "description": "Should also be filtered",
34 }
35 ]
36 }
37 }
38 with patch("lib.grounding.http.request", return_value=mock_response) as mock_req:
39 items, artifact = grounding.brave_search("test", ("2026-02-25", "2026-03-27"), "fake-key")
40 self.assertEqual(1, len(items))
41 self.assertEqual("Test Article", items[0]["title"])
42 self.assertEqual("https://example.com/article", items[0]["url"])
43 self.assertEqual("2026-03-10", items[0]["date"])
44 self.assertEqual("brave", artifact["label"])
45 call_url = mock_req.call_args.args[1]
46 self.assertIn("freshness=2026-02-25to2026-03-27", call_url)
47
48
49 class SerperSearchTests(unittest.TestCase):
50 def test_serper_search_filters_to_in_range_dated_items(self):
51 mock_response = {
52 "organic": [
53 {
54 "title": "Serper Result",
55 "link": "https://example.com/serper",
56 "snippet": "A serper snippet",
57 "date": "Mar 15, 2026",
58 },
59 {
60 "title": "Old Result",
61 "link": "https://example.com/old",
62 "snippet": "Should be filtered",
63 "date": "Jan 15, 2026",
64 },
65 {
66 "title": "Undated Result",
67 "link": "https://example.com/undated",
68 "snippet": "Should also be filtered",
69 }
70 ]
71 }
72 with patch("lib.grounding.http.request", return_value=mock_response):
73 items, artifact = grounding.serper_search("test", ("2026-02-25", "2026-03-27"), "fake-key")
74 self.assertEqual(1, len(items))
75 self.assertEqual("Serper Result", items[0]["title"])
76 self.assertEqual("2026-03-15", items[0]["date"])
77 self.assertEqual("serper", artifact["label"])
78
79
80 class ExaSearchTests(unittest.TestCase):
81 def test_exa_search_filters_to_in_range_dated_items(self):
82 mock_response = {
83 "results": [
84 {
85 "title": "Exa Result",
86 "url": "https://example.com/exa",
87 "text": "An exa snippet about AI trends",
88 "publishedDate": "2026-03-15T00:00:00.000Z",
89 "score": 0.85,
90 },
91 {
92 "title": "Old Exa Result",
93 "url": "https://example.com/old-exa",
94 "text": "Should be filtered out",
95 "publishedDate": "2025-12-01T00:00:00.000Z",
96 "score": 0.7,
97 },
98 {
99 "title": "Undated Exa Result",
100 "url": "https://example.com/undated-exa",
101 "text": "No date means filtered",
102 },
103 ]
104 }
105 with patch("lib.grounding.http.request", return_value=mock_response) as mock_req:
106 items, artifact = grounding.exa_search("test", ("2026-02-25", "2026-03-27"), "fake-exa-key")
107 self.assertEqual(1, len(items))
108 self.assertEqual("Exa Result", items[0]["title"])
109 self.assertEqual("https://example.com/exa", items[0]["url"])
110 self.assertEqual("2026-03-15", items[0]["date"])
111 self.assertTrue(items[0]["id"].startswith("WE"))
112 self.assertEqual("exa", artifact["label"])
113 self.assertEqual(1, artifact["resultCount"])
114 # Verify API call
115 call_args = mock_req.call_args
116 self.assertEqual("POST", call_args.args[0])
117 self.assertEqual("https://api.exa.ai/search", call_args.args[1])
118 self.assertEqual("fake-exa-key", call_args.kwargs["headers"]["x-api-key"])
119
120 def test_exa_search_returns_empty_for_no_results(self):
121 with patch("lib.grounding.http.request", return_value={"results": []}):
122 items, artifact = grounding.exa_search("test", ("2026-02-25", "2026-03-27"), "key")
123 self.assertEqual([], items)
124 self.assertEqual(0, artifact["resultCount"])
125
126
127 class ParallelSearchTests(unittest.TestCase):
128 def test_parallel_search_filters_to_in_range_dated_items(self):
129 mock_response = {
130 "results": [
131 {
132 "title": "Parallel Result",
133 "url": "https://example.com/parallel",
134 "snippet": "A parallel snippet",
135 "publish_date": "2026-03-15T00:00:00Z",
136 },
137 {
138 "title": "Old Parallel Result",
139 "url": "https://example.com/old-parallel",
140 "snippet": "Should be filtered",
141 "publish_date": "2025-12-01T00:00:00Z",
142 },
143 {
144 "title": "Undated Parallel Result",
145 "url": "https://example.com/undated-parallel",
146 "snippet": "Should also be filtered",
147 },
148 ]
149 }
150 with patch("lib.grounding.http.request", return_value=mock_response) as mock_req:
151 items, artifact = grounding.parallel_search(
152 "test", ("2026-02-25", "2026-03-27"), "fake-parallel-key"
153 )
154 self.assertEqual(1, len(items))
155 self.assertEqual("Parallel Result", items[0]["title"])
156 self.assertEqual("https://example.com/parallel", items[0]["url"])
157 self.assertEqual("2026-03-15", items[0]["date"])
158 self.assertTrue(items[0]["id"].startswith("WP"))
159 self.assertEqual("parallel", artifact["label"])
160 self.assertEqual(1, artifact["resultCount"])
161 self.assertEqual("POST", mock_req.call_args.args[0])
162 self.assertEqual("https://api.parallel.ai/v1/search", mock_req.call_args.args[1])
163 self.assertEqual(
164 "Bearer fake-parallel-key",
165 mock_req.call_args.kwargs["headers"]["Authorization"],
166 )
167
168 def test_parallel_search_returns_empty_for_no_results(self):
169 with patch("lib.grounding.http.request", return_value={"results": []}):
170 items, artifact = grounding.parallel_search("test", ("2026-02-25", "2026-03-27"), "key")
171 self.assertEqual([], items)
172 self.assertEqual(0, artifact["resultCount"])
173
174
175 class WebSearchDispatchTests(unittest.TestCase):
176 def test_auto_selects_brave_when_key_present(self):
177 config = {"BRAVE_API_KEY": "test-key"}
178 with patch("lib.grounding.brave_search", return_value=([], {})) as mock:
179 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
180 mock.assert_called_once()
181
182 def test_auto_selects_exa_when_only_exa_key(self):
183 config = {"EXA_API_KEY": "test-key"}
184 with patch("lib.grounding.exa_search", return_value=([], {})) as mock:
185 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
186 mock.assert_called_once()
187
188 def test_auto_selects_serper_when_only_serper_key(self):
189 config = {"SERPER_API_KEY": "test-key"}
190 with patch("lib.grounding.serper_search", return_value=([], {})) as mock:
191 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
192 mock.assert_called_once()
193
194 def test_auto_selects_parallel_when_only_parallel_key(self):
195 config = {"PARALLEL_API_KEY": "test-key"}
196 with patch("lib.grounding.parallel_search", return_value=([], {})) as mock:
197 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
198 mock.assert_called_once()
199
200 def test_auto_returns_empty_when_no_keys_and_native_search(self):
201 # On a native-search host (signal set) with no paid key, the engine
202 # leaves general web to the model's own search and returns nothing.
203 config = {"LAST30DAYS_NATIVE_SEARCH": "1"}
204 items, artifact = grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
205 self.assertEqual([], items)
206 self.assertEqual({}, artifact)
207
208 def test_auto_falls_to_keyless_when_no_keys_and_no_native_search(self):
209 # No paid key and no native search -> keyless floor is used.
210 with patch("lib.grounding.web_search_keyless.keyless_search",
211 return_value=([], {"label": "keyless"})) as mock_keyless:
212 grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="auto")
213 mock_keyless.assert_called_once()
214
215 def test_explicit_keyless_backend_invokes_keyless(self):
216 with patch("lib.grounding.web_search_keyless.keyless_search",
217 return_value=([], {"label": "keyless"})) as mock_keyless:
218 grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="keyless")
219 mock_keyless.assert_called_once()
220
221 def test_none_returns_empty(self):
222 config = {"BRAVE_API_KEY": "test-key"}
223 items, artifact = grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="none")
224 self.assertEqual([], items)
225
226 def test_auto_prefers_brave_over_exa(self):
227 config = {"BRAVE_API_KEY": "brave-key", "EXA_API_KEY": "exa-key"}
228 with patch("lib.grounding.brave_search", return_value=([], {})) as mock_brave, \
229 patch("lib.grounding.exa_search", return_value=([], {})) as mock_exa:
230 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
231 mock_brave.assert_called_once()
232 mock_exa.assert_not_called()
233
234 def test_auto_prefers_exa_over_serper(self):
235 config = {"EXA_API_KEY": "exa-key", "SERPER_API_KEY": "serper-key"}
236 with patch("lib.grounding.exa_search", return_value=([], {})) as mock_exa, \
237 patch("lib.grounding.serper_search", return_value=([], {})) as mock_serper:
238 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
239 mock_exa.assert_called_once()
240 mock_serper.assert_not_called()
241
242 def test_auto_prefers_serper_over_parallel(self):
243 config = {"SERPER_API_KEY": "serper-key", "PARALLEL_API_KEY": "parallel-key"}
244 with patch("lib.grounding.serper_search", return_value=([], {})) as mock_serper, \
245 patch("lib.grounding.parallel_search", return_value=([], {})) as mock_parallel:
246 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
247 mock_serper.assert_called_once()
248 mock_parallel.assert_not_called()
249
250 def test_auto_prefers_brave_when_all_keys_present(self):
251 config = {"BRAVE_API_KEY": "brave-key", "EXA_API_KEY": "exa-key", "SERPER_API_KEY": "serper-key"}
252 with patch("lib.grounding.brave_search", return_value=([], {})) as mock_brave, \
253 patch("lib.grounding.exa_search", return_value=([], {})) as mock_exa, \
254 patch("lib.grounding.serper_search", return_value=([], {})) as mock_serper:
255 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
256 mock_brave.assert_called_once()
257 mock_exa.assert_not_called()
258 mock_serper.assert_not_called()
259
260 def test_explicit_exa_without_key_raises(self):
261 with self.assertRaises(RuntimeError):
262 grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="exa")
263
264 def test_explicit_brave_without_key_raises(self):
265 with self.assertRaises(RuntimeError):
266 grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="brave")
267
268 def test_explicit_parallel_without_key_raises(self):
269 with self.assertRaises(RuntimeError):
270 grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="parallel")
271
272 def test_unsupported_backend_raises(self):
273 with self.assertRaises(ValueError):
274 grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="google")
275
276
277 class RedditEnrichmentGateTests(unittest.TestCase):
278 """EXCLUDE_SOURCES=reddit must suppress the web-search Reddit enrichment.
279
280 Otherwise a user who explicitly excluded Reddit would still get Reddit
281 content smuggled back in via web-search URLs that happen to point at
282 reddit.com threads.
283 """
284
285 def test_reddit_excluded_via_exclude_sources_skips_enrichment(self):
286 config = {"BRAVE_API_KEY": "k", "EXCLUDE_SOURCES": "reddit"}
287 items = [{"url": "https://www.reddit.com/r/python/comments/abc/title/", "snippet": "original"}]
288 with patch("lib.grounding.brave_search", return_value=(items, {})), \
289 patch("lib.grounding._enrich_reddit_items") as enrich_mock:
290 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
291 enrich_mock.assert_not_called()
292
293 def test_reddit_excluded_case_insensitive(self):
294 for value in ("REDDIT", "Reddit", " reddit ", "x,reddit,y"):
295 config = {"BRAVE_API_KEY": "k", "EXCLUDE_SOURCES": value}
296 self.assertTrue(
297 grounding._reddit_excluded(config),
298 msg=f"_reddit_excluded should be True for EXCLUDE_SOURCES={value!r}",
299 )
300
301 def test_reddit_not_excluded_when_other_sources_listed(self):
302 config = {"EXCLUDE_SOURCES": "tiktok,instagram"}
303 self.assertFalse(grounding._reddit_excluded(config))
304
305 def test_enrichment_runs_when_reddit_not_excluded(self):
306 config = {"BRAVE_API_KEY": "k"}
307 items = [{"url": "https://www.reddit.com/r/python/comments/abc/title/", "snippet": "original"}]
308 with patch("lib.grounding.brave_search", return_value=(items, {})), \
309 patch("lib.grounding._enrich_reddit_items", return_value=items) as enrich_mock:
310 grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
311 enrich_mock.assert_called_once()
312
313
314 class RedditEnrichItemsTests(unittest.TestCase):
315 """Direct tests for `_enrich_reddit_items` covering the selftext key path
316 and the RedditRateLimitError early-exit behavior.
317 """
318
319 def test_selftext_under_submission_populates_snippet(self):
320 from lib import reddit_enrich
321
322 item = {
323 "url": "https://www.reddit.com/r/python/comments/abc/title/",
324 "snippet": "original",
325 }
326 parsed = {
327 "submission": {"selftext": "thread body content"},
328 "comments": [],
329 }
330 with patch.object(reddit_enrich, "fetch_thread_data", return_value={"raw": True}), \
331 patch.object(reddit_enrich, "parse_thread_data", return_value=parsed):
332 result = grounding._enrich_reddit_items([item])
333 self.assertEqual("thread body content", result[0]["snippet"])
334 self.assertEqual("reddit_json_api", result[0]["enriched_via"])
335
336 def test_rate_limit_error_halts_iteration(self):
337 from lib import reddit_enrich
338
339 item1 = {"url": "https://www.reddit.com/r/python/comments/aaa/x/"}
340 item2 = {"url": "https://www.reddit.com/r/python/comments/bbb/y/"}
341
342 def fake_fetch(url, *args, **kwargs):
343 raise reddit_enrich.RedditRateLimitError(f"429 for {url}")
344
345 captured_stderr: list[str] = []
346
347 with patch.object(reddit_enrich, "fetch_thread_data", side_effect=fake_fetch) as fetch_mock, \
348 patch("lib.grounding.sys.stderr.write", side_effect=lambda s: captured_stderr.append(s)):
349 grounding._enrich_reddit_items([item1, item2])
350
351 # Only the first item should have triggered a fetch attempt
352 self.assertEqual(1, fetch_mock.call_count)
353 # A stderr message about the rate-limit halt should have been emitted
354 self.assertTrue(
355 any("rate-limited" in msg.lower() or "rate limited" in msg.lower() for msg in captured_stderr),
356 msg=f"Expected a rate-limit stderr message, got: {captured_stderr!r}",
357 )
358
359 class RedditEnrichmentIsolationTests(unittest.TestCase):
360 def test_enrichment_http_failure_does_not_poison_web_source(self):
361 """A reddit.com enrichment fetch failure (e.g. a 403 on a datacenter IP)
362 is a secondary operation on already-retrieved web results; it must not be
363 attributed to the web/grounding source and discard those results."""
364 from lib import http
365
366 retrieved = [
367 {"url": "https://www.reddit.com/r/x/comments/1/abc/", "title": "T", "snippet": "s"},
368 ]
369
370 def fake_enrich(items):
371 # The enricher swallows the error, but the http layer records the
372 # terminal failure into whatever capture sink is currently active.
373 http._record_failure(http.HTTPError("Blocked", status_code=403))
374 return items
375
376 with http.capture_failures() as source_sink:
377 with patch.object(grounding, "web_search_keyless") as wsk, \
378 patch.object(grounding, "_enrich_reddit_items", side_effect=fake_enrich):
379 wsk.keyless_search.return_value = (list(retrieved), {"keyless_backend": "startpage"})
380 items, _ = grounding.web_search(
381 "q", ("2026-02-25", "2026-03-27"), {}, backend="keyless")
382
383 self.assertEqual(len(items), 1)
384 # The enrichment 403 is isolated in its own sink; the source's sink is clean.
385 self.assertEqual(source_sink, [])
386
387
388 class ParallelMCPRuntimeTests(unittest.TestCase):
389 class _Response:
390 def __init__(self, payload, session=None, content_type="application/json"):
391 raw = payload if isinstance(payload, bytes) else json.dumps(payload).encode() if payload is not None else b""
392 self.body = io.BytesIO(raw)
393 self.headers = {"Content-Type": content_type}
394 if session:
395 self.headers["Mcp-Session-Id"] = session
396 def __enter__(self):
397 return self
398 def __exit__(self, *_args):
399 return False
400 def read(self, size=-1):
401 return self.body.read(size)
402 def readline(self, size=-1):
403 return self.body.readline(size)
404
405 def test_explicit_parallel_mcp_discovers_invokes_and_maps_anonymous_result(self):
406 responses = iter([
407 self._Response({"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-03-26"}}, "session-1"),
408 self._Response(None),
409 self._Response({"jsonrpc": "2.0", "id": 2, "result": {"tools": [{"name": "web_search"}, {"name": "web_fetch"}]}}),
410 self._Response({"jsonrpc": "2.0", "id": 3, "result": {"structuredContent": {"results": [{"url": "https://example.com/evidence", "title": None, "publish_date": "2026-08-01", "excerpts": ["Useful evidence"]}]}}}),
411 self._Response(None),
412 ])
413 requests = []
414 def open_response(request, timeout):
415 requests.append(request)
416 return next(responses)
417 with patch("lib.parallel_mcp.urllib.request.OpenerDirector.open", side_effect=open_response):
418 items, artifact = grounding.web_search(
419 "agent runtimes", ("2026-07-27", "2026-08-26"), {}, backend="parallel-mcp"
420 )
421 self.assertEqual("https://example.com/evidence", items[0]["url"])
422 self.assertEqual("Useful evidence", items[0]["snippet"])
423 self.assertEqual("2026-08-01", items[0]["date"])
424 self.assertEqual("parallel-mcp", artifact["label"])
425 self.assertTrue(all(request.get_header("Authorization") is None for request in requests))
426 self.assertTrue(all(request.get_header("Mcp-session-id") == "session-1" for request in requests[1:]))
427 self.assertTrue(all(request.get_header("Mcp-protocol-version") == "2025-03-26" for request in requests[1:]))
428 self.assertEqual("DELETE", requests[-1].method)
429 messages = [json.loads(request.data) for request in requests if request.data]
430 self.assertEqual(["initialize", "notifications/initialized", "tools/list", "tools/call"], [message["method"] for message in messages])
431 self.assertEqual("web_search", messages[-1]["params"]["name"])
432 self.assertEqual(["agent runtimes"], messages[-1]["params"]["arguments"]["search_queries"])
433 self.assertNotIn("max_results", messages[-1]["params"]["arguments"])
434
435 def test_parallel_mcp_rejects_oversized_response(self):
436 class OversizedResponse(self._Response):
437 def read(self, size=-1):
438 self.requested_size = size
439 return b"x" * size
440 response = OversizedResponse(None)
441 with patch("lib.parallel_mcp.urllib.request.OpenerDirector.open", return_value=response):
442 with self.assertRaisesRegex(RuntimeError, "exceeded 4 MiB"):
443 grounding.web_search(
444 "test", ("2026-07-27", "2026-08-26"), {}, backend="parallel-mcp"
445 )
446 self.assertEqual(4 * 1024 * 1024 + 1, response.requested_size)
447
448 def test_auto_without_opt_in_does_not_contact_parallel_mcp(self):
449 with patch("lib.grounding.parallel_mcp.search") as mcp_search, \
450 patch("lib.grounding.web_search_keyless.keyless_search", return_value=([], {})):
451 grounding.web_search("test", ("2026-07-27", "2026-08-26"), {}, backend="auto")
452 mcp_search.assert_not_called()
453
454 def test_explicit_parallel_mcp_preserves_optional_bearer_auth(self):
455 with patch("lib.grounding.parallel_mcp.search", return_value=([], {})) as mcp_search:
456 grounding.web_search(
457 "test", ("2026-07-27", "2026-08-26"),
458 {"PARALLEL_API_KEY": "test-key"}, backend="parallel-mcp",
459 )
460 mcp_search.assert_called_once_with("test", ("2026-07-27", "2026-08-26"), "test-key")
461
462 @contextmanager
463 def _server(self, handler):
464 server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
465 thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True)
466 thread.start()
467 try:
468 yield f"http://127.0.0.1:{server.server_port}"
469 finally:
470 server.shutdown()
471 server.server_close()
472 thread.join()
473
474 def test_redirects_never_forward_auth_session_or_search_data(self):
475 target_requests = []
476 original_requests = []
477
478 class Target(BaseHTTPRequestHandler):
479 def do_GET(self):
480 target_requests.append(dict(self.headers))
481 self.send_response(200)
482 self.end_headers()
483 do_POST = do_GET
484 def log_message(self, *_args):
485 pass
486
487 class Redirect(Target):
488 def do_POST(self):
489 original_requests.append(dict(self.headers))
490 self.rfile.read(int(self.headers.get("Content-Length", 0)))
491 self.send_response(int(self.path.strip("/")))
492 self.send_header("Location", target_url)
493 self.end_headers()
494
495 with self._server(Target) as target_url, self._server(Redirect) as source_url:
496 for status in (301, 302, 303, 307, 308):
497 with self.subTest(status=status), patch.object(parallel_mcp, "PARALLEL_MCP_URL", f"{source_url}/{status}"):
498 with self.assertRaises(urllib.error.HTTPError) as raised:
499 parallel_mcp._request(
500 {"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
501 "test-key", "test-session",
502 )
503 self.assertEqual(status, raised.exception.code)
504 raised.exception.close()
505 self.assertEqual([], target_requests)
506 self.assertEqual(5, len(original_requests))
507 self.assertTrue(all(headers["Authorization"] == "Bearer test-key" for headers in original_requests))
508 self.assertTrue(all(headers["Mcp-Session-Id"] == "test-session" for headers in original_requests))
509
510 def test_sse_matches_request_and_returns_without_waiting_for_eof(self):
511 class OpenStream(self._Response):
512 def read(self, size=-1):
513 raise AssertionError("SSE must not wait for the whole body")
514 def readline(self, size=-1):
515 line = super().readline(size)
516 if not line:
517 raise AssertionError("SSE must stop at the matching response")
518 return line
519
520 response = OpenStream(
521 b': heartbeat\r\n\r\n'
522 b'data: {"jsonrpc":"2.0","method":"notifications/progress"}\r\n\r\n'
523 b'data: {"jsonrpc":"2.0","id":99,"result":{}}\r\n\r\n'
524 b'event: message\r\ndata: {"jsonrpc":"2.0",\r\ndata: "id":3,"result":{"content":[]}}\r\n\r\n',
525 content_type="text/event-stream; charset=utf-8",
526 )
527 self.assertEqual({"content": []}, parallel_mcp._read_response(response, 3)["result"])
528
529 def test_sse_supports_batched_messages(self):
530 payload = b'data: [{"jsonrpc":"2.0","method":"notifications/progress"},{"jsonrpc":"2.0","id":3,"result":{}}]\n\n'
531 response = self._Response(payload, content_type="text/event-stream")
532 self.assertEqual(3, parallel_mcp._read_response(response, 3)["id"])
533
534 def test_sse_response_cap_includes_heartbeats_and_all_events(self):
535 for payload in (b":" + b"x" * 80, b": heartbeat\n\n" * 10):
536 with self.subTest(payload=payload), patch.object(parallel_mcp, "_MAX_RESPONSE_BYTES", 64):
537 response = self._Response(payload, content_type="text/event-stream")
538 with self.assertRaisesRegex(RuntimeError, "exceeded 4 MiB"):
539 parallel_mcp._read_response(response, 3)
540
541 def test_rejects_missing_or_mismatched_rpc_response(self):
542 for payload, content_type in (
543 ({"jsonrpc": "2.0", "id": 99, "result": {}}, "application/json"),
544 (None, "application/json"),
545 (b'data: {"jsonrpc":"2.0","method":"notifications/progress"}\n\n', "text/event-stream"),
546 ):
547 with self.subTest(payload=payload):
548 with self.assertRaisesRegex(RuntimeError, "missing the requested"):
549 parallel_mcp._read_response(self._Response(payload, content_type=content_type), 3)
550
551 def test_filters_dates_and_invalid_urls_before_applying_result_limit(self):
552 rows = [
553 {"url": "https://example.com/old", "publish_date": "2026-07-26"},
554 {"url": "https://example.com/future", "publish_date": "2026-08-27"},
555 {"url": "https://example.com/undated"},
556 {"url": "https://example.com/invalid-date", "publish_date": "not-a-date"},
557 {"url": "https:///missing-host", "publish_date": "2026-08-01"},
558 {"url": "https://[invalid", "publish_date": "2026-08-01"},
559 {"url": "https://example.com/start", "publish_date": "2026-07-27T12:00:00Z", "excerpts": "Start evidence"},
560 {"url": "https://example.com/end", "publish_date": "2026-08-26", "excerpts": ["End evidence"]},
561 {"url": "https://example.com/extra", "publish_date": "2026-08-01"},
562 ]
563 responses = [
564 ({"result": {"protocolVersion": "2025-03-26"}}, None),
565 ({}, None),
566 ({"result": {"tools": [{"name": "web_search"}]}}, None),
567 ({"result": {"content": [{"type": "text", "text": json.dumps({"results": rows})}]}}, None),
568 ]
569 with patch.object(parallel_mcp, "_request", side_effect=responses):
570 items, artifact = parallel_mcp.search("test", ("2026-07-27", "2026-08-26"), count=2)
571 self.assertEqual(["2026-07-27", "2026-08-26"], [item["date"] for item in items])
572 self.assertEqual(["Start evidence", "End evidence"], [item["snippet"] for item in items])
573 self.assertEqual(2, artifact["resultCount"])
574
575 def test_empty_results_are_distinct_from_malformed_payloads(self):
576 self.assertEqual([], parallel_mcp._search_rows({"structuredContent": {"results": []}}))
577 for value in (42, "invalid", {"results": 42}):
578 with self.subTest(value=value), self.assertRaisesRegex(RuntimeError, "no results array"):
579 parallel_mcp._search_rows({"structuredContent": value})
580
581 def test_discovers_web_search_on_later_page_and_cleanup_failure_is_nonfatal(self):
582 responses = [
583 ({"result": {"protocolVersion": "2025-03-26"}}, "session-1"),
584 ({}, "session-1"),
585 ({"result": {"tools": [{"name": "web_fetch"}], "nextCursor": "page-2"}}, "session-1"),
586 ({"result": {"tools": [{"name": "web_search"}]}}, "session-1"),
587 ({"result": {"structuredContent": {"results": []}}}, "session-1"),
588 urllib.error.HTTPError(parallel_mcp.PARALLEL_MCP_URL, 405, "Not allowed", {}, None),
589 ]
590 with patch.object(parallel_mcp, "_request", side_effect=responses) as request:
591 items, _ = parallel_mcp.search("test", ("2026-07-27", "2026-08-26"))
592 self.assertEqual([], items)
593 self.assertEqual({"cursor": "page-2"}, request.call_args_list[3].args[0]["params"])
594 self.assertEqual(4, request.call_args_list[4].args[0]["id"])
595 self.assertEqual((None, None, "session-1"), request.call_args_list[-1].args)
596
597 def test_failed_discovery_still_terminates_session_and_preserves_error(self):
598 responses = [
599 ({"result": {"protocolVersion": "2025-03-26"}}, "session-1"),
600 ({}, "session-1"),
601 ({"error": {"message": "Discovery failed"}}, "session-1"),
602 OSError("Cleanup failed"),
603 ]
604 with patch.object(parallel_mcp, "_request", side_effect=responses) as request:
605 with self.assertRaisesRegex(RuntimeError, "Discovery failed"):
606 parallel_mcp.search("test", ("2026-07-27", "2026-08-26"))
607 self.assertIsNone(request.call_args.args[0])
608
609 def test_unsupported_protocol_is_rejected_before_search(self):
610 responses = [({"result": {"protocolVersion": "unsupported"}}, "session-1"), ({}, None)]
611 with patch.object(parallel_mcp, "_request", side_effect=responses) as request:
612 with self.assertRaisesRegex(RuntimeError, "unsupported protocol version"):
613 parallel_mcp.search("test", ("2026-07-27", "2026-08-26"))
614 self.assertEqual(2, request.call_count)
615 self.assertIsNone(request.call_args.args[0])
616
617
618 if __name__ == "__main__":
619 unittest.main()
620
620 lines PYTHON