"""Unit tests for ``advanced_search_system.tools.fetch.build_fetch_tool``. Pins the mode dispatch and the prompt-content contract for the two summary variants (focus-only vs focus + overall query). Avoids any real HTTP — patches ``ContentFetcher`` and the model. """ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest from local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy import ( SearchResultsCollector, ) from local_deep_research.advanced_search_system.tools.fetch import ( FETCH_MODES, build_fetch_tool, ) def _fetcher_cm(*, status="success", title="Page", content="Body text"): fetcher = MagicMock() fetcher.fetch.return_value = { "status": status, "title": title, "content": content, } cm = MagicMock() cm.__enter__.return_value = fetcher cm.__exit__.return_value = False return cm def _model_returning(text: str): """A model whose ``invoke`` returns an object with ``.content == text``.""" msg = MagicMock() msg.content = text model = MagicMock() model.invoke.return_value = msg return model def test_fetch_modes_constant_lists_all_supported_values(): assert FETCH_MODES == ( "disabled", "full", "summary_focus", "summary_focus_query", ) def test_disabled_mode_returns_none(): assert build_fetch_tool("disabled", SearchResultsCollector([])) is None def test_full_mode_returns_tool_with_url_only_signature(): tool = build_fetch_tool("full", SearchResultsCollector([])) assert tool is not None assert "url" in tool.args assert "focus" not in tool.args def test_summary_focus_requires_model(): with pytest.raises(ValueError, match="summary_focus"): build_fetch_tool("summary_focus", SearchResultsCollector([])) def test_summary_focus_query_requires_model(): with pytest.raises(ValueError, match="summary_focus_query"): build_fetch_tool("summary_focus_query", SearchResultsCollector([])) def test_summary_focus_tool_calls_model_with_focus_only_prompt(): collector = SearchResultsCollector([]) model = _model_returning("relevant fact 1") tool = build_fetch_tool( "summary_focus", collector, model=model, overall_query="should not appear", ) assert tool is not None assert "focus" in tool.args cm = _fetcher_cm(title="T", content="page body") with patch( "local_deep_research.content_fetcher.ContentFetcher", return_value=cm ): out = tool.invoke({"url": "http://example.com/", "focus": "year of X"}) model.invoke.assert_called_once() prompt = model.invoke.call_args[0][0] # focus-only mode must NOT mention the overall query, even if one was passed assert "Why this page was fetched: year of X" in prompt assert "Overall research question" not in prompt # Output is prefixed with citation index so the agent can cite consistently assert out.startswith("[1] ") assert "relevant fact 1" in out def test_summary_focus_query_includes_overall_query_in_prompt(): collector = SearchResultsCollector([]) model = _model_returning("relevant fact 2") tool = build_fetch_tool( "summary_focus_query", collector, model=model, overall_query="When did Liepmann receive the Prandtl-Ring Award?", ) cm = _fetcher_cm(title="T", content="page body") with patch( "local_deep_research.content_fetcher.ContentFetcher", return_value=cm ): tool.invoke({"url": "http://example.com/", "focus": "year"}) prompt = model.invoke.call_args[0][0] assert ( "Overall research question: When did Liepmann receive the Prandtl-Ring Award?" in prompt ) assert "Why this page was fetched: year" in prompt def test_summary_focus_query_with_empty_overall_query_falls_back_to_focus_only(): """An empty overall_query should not produce a stale 'Overall research question:' line.""" collector = SearchResultsCollector([]) model = _model_returning("ok") tool = build_fetch_tool( "summary_focus_query", collector, model=model, overall_query="", # empty ) cm = _fetcher_cm() with patch( "local_deep_research.content_fetcher.ContentFetcher", return_value=cm ): tool.invoke({"url": "http://example.com/", "focus": "x"}) prompt = model.invoke.call_args[0][0] assert "Overall research question" not in prompt def test_unknown_mode_raises_with_valid_modes_listed(): with pytest.raises(ValueError, match="Unknown fetch mode"): build_fetch_tool("magic", SearchResultsCollector([])) # ---- issue #3826: web.enable_javascript_rendering plumbing ---- def _captured_content_fetcher_kwargs(invoke_target): """Run *invoke_target* with ContentFetcher patched and return its constructor kwargs from the call. The patched factory returns a context manager whose ``fetch`` succeeds so the tool body runs end-to-end up to the call we care about. """ cm = _fetcher_cm() with patch( "local_deep_research.content_fetcher.ContentFetcher", return_value=cm ) as factory: invoke_target() assert factory.call_args is not None return factory.call_args.kwargs def test_full_mode_passes_js_off_when_snapshot_disables_it(): """Full-mode fetch tool must pass enable_js_rendering=False to ContentFetcher when the snapshot disables JS rendering.""" collector = SearchResultsCollector([]) snapshot = { "web.enable_javascript_rendering": { "value": False, "ui_element": "checkbox", } } tool = build_fetch_tool("full", collector, settings_snapshot=snapshot) kwargs = _captured_content_fetcher_kwargs( lambda: tool.invoke({"url": "http://example.com/"}) ) assert kwargs.get("enable_js_rendering") is False def test_full_mode_passes_js_on_when_snapshot_enables_it(): """When the snapshot opts in, JS rendering is forwarded to ContentFetcher.""" collector = SearchResultsCollector([]) snapshot = { "web.enable_javascript_rendering": { "value": True, "ui_element": "checkbox", } } tool = build_fetch_tool("full", collector, settings_snapshot=snapshot) kwargs = _captured_content_fetcher_kwargs( lambda: tool.invoke({"url": "http://example.com/"}) ) assert kwargs.get("enable_js_rendering") is True def test_full_mode_defaults_to_js_off_without_snapshot(): """No snapshot, no thread-local context → JS disabled (safe default).""" collector = SearchResultsCollector([]) tool = build_fetch_tool("full", collector) kwargs = _captured_content_fetcher_kwargs( lambda: tool.invoke({"url": "http://example.com/"}) ) assert kwargs.get("enable_js_rendering") is False def test_fetch_error_scrubs_credentialed_url_from_exception(): """A fetch exception that embeds a credentialed URL must be scrubbed before the tool return reaches the agent/LLM and the user-visible research output (credential-leak follow-up to #4625). Full detail stays in the server log.""" collector = SearchResultsCollector([]) tool = build_fetch_tool("full", collector) fetcher = MagicMock() fetcher.fetch.side_effect = Exception( "ConnectionError for https://user:SUPERSECRET123@proxy.example.com:8080" ) cm = MagicMock() cm.__enter__.return_value = fetcher cm.__exit__.return_value = False with patch( "local_deep_research.content_fetcher.ContentFetcher", return_value=cm ): out = tool.invoke({"url": "http://example.com/"}) assert "SUPERSECRET123" not in out # credential scrubbed assert "Error fetching" in out # still a useful error for the agent def test_failed_fetch_scrubs_credentials_from_result_error(): """A non-success fetch result carries ContentFetcher's raw error string, which can embed a credentialed URL. It must be scrubbed before the 'Failed to fetch' tool return reaches the agent/LLM (#4633).""" collector = SearchResultsCollector([]) tool = build_fetch_tool("full", collector) fetcher = MagicMock() fetcher.fetch.return_value = { "status": "error", "error": "blocked: https://user:SUPERSECRET999@cdn.example.com", } cm = MagicMock() cm.__enter__.return_value = fetcher cm.__exit__.return_value = False with patch( "local_deep_research.content_fetcher.ContentFetcher", return_value=cm ): out = tool.invoke({"url": "http://example.com/"}) assert "SUPERSECRET999" not in out # credential scrubbed assert "Failed to fetch" in out # still a useful error for the agent def test_summary_mode_forwards_js_setting(): """Summary-mode tool also forwards the JS toggle from the snapshot.""" collector = SearchResultsCollector([]) model = _model_returning("ok") snapshot = { "web.enable_javascript_rendering": { "value": False, "ui_element": "checkbox", } } tool = build_fetch_tool( "summary_focus", collector, model=model, settings_snapshot=snapshot, ) kwargs = _captured_content_fetcher_kwargs( lambda: tool.invoke({"url": "http://example.com/", "focus": "x"}) ) assert kwargs.get("enable_js_rendering") is False # --------------------------------------------------------------------------- # Egress policy: an out-of-scope URL is a RECOVERABLE tool message, not a # raised exception. Re-raising used to abort a pooled subagent (and depended # on each agent's tool-error layer); returning a message lets the lead agent # and subagents handle it identically and stay in-scope. The URL is never # fetched either way — only the reporting changes. # --------------------------------------------------------------------------- def test_full_mode_egress_denial_returns_message_not_raises(): from local_deep_research.security.egress.policy import Decision collector = SearchResultsCollector([]) tool = build_fetch_tool("full", collector, egress_context=object()) with patch( "local_deep_research.security.egress.policy.evaluate_url", return_value=Decision(False, "scope_mismatch_private_only"), ): out = tool.invoke({"url": "https://public.example.com/x"}) assert isinstance(out, str) assert "Cannot fetch" in out assert "scope_mismatch_private_only" in out # the reason is surfaced assert "skip external URLs" in out # the adapt-and-stay-local hint # The denied URL must never be registered as a citation. assert collector.results == [] def test_summary_mode_egress_denial_returns_message_not_raises(): from local_deep_research.security.egress.policy import Decision collector = SearchResultsCollector([]) model = _model_returning("should-never-be-called") tool = build_fetch_tool( "summary_focus", collector, model=model, egress_context=object() ) with patch( "local_deep_research.security.egress.policy.evaluate_url", return_value=Decision(False, "scope_mismatch_private_only"), ): out = tool.invoke({"url": "https://public.example.com/x", "focus": "y"}) assert isinstance(out, str) assert "Cannot fetch" in out assert "scope_mismatch_private_only" in out # The gate fires BEFORE any fetch/summarise work. model.invoke.assert_not_called() assert collector.results == [] def test_fetch_error_log_redacts_url_and_adds_mode(): """The server-log line for a fetch error carries the MODE and a REDACTED scheme://host (no userinfo / path / query) — enough to locate the failure without leaking credentials, query tokens, page paths, or content. Asserted on the args passed to logger.exception (robust to loguru sink config).""" collector = SearchResultsCollector([]) tool = build_fetch_tool("full", collector) fetcher = MagicMock() fetcher.fetch.side_effect = Exception("boom") cm = MagicMock() cm.__enter__.return_value = fetcher cm.__exit__.return_value = False url = ( "https://user:SECRET123@proxy.example.com:8080/secretpath?token=ABCXYZ" ) with patch( "local_deep_research.advanced_search_system.tools.fetch.logger" ) as mock_logger: with patch( "local_deep_research.content_fetcher.ContentFetcher", return_value=cm, ): tool.invoke({"url": url}) mock_logger.exception.assert_called_once() args = mock_logger.exception.call_args.args # args = (template, mode_label, redacted_url) assert "mode={}" in args[0] and "url={}" in args[0] assert "full" in args # the mode assert ( "https://proxy.example.com:8080" in args ) # redacted scheme://host:port flat = " ".join(map(str, args)) assert "SECRET123" not in flat # userinfo dropped assert "ABCXYZ" not in flat # query token dropped assert "secretpath" not in flat # path dropped