/ tests / tools / test_browser_content_none_guard.py
test_browser_content_none_guard.py
  1  """Tests for None guard on browser_tool LLM response content.
  2  
  3  browser_tool.py has two call sites that access response.choices[0].message.content
  4  without checking for None — _extract_relevant_content (line 996) and
  5  browser_vision (line 1626). When reasoning-only models (DeepSeek-R1, QwQ)
  6  return content=None, these produce null snapshots or null analysis.
  7  
  8  These tests verify both sites are guarded.
  9  """
 10  
 11  import types
 12  from unittest.mock import MagicMock, patch
 13  
 14  import pytest
 15  
 16  
 17  # ── helpers ────────────────────────────────────────────────────────────────
 18  
 19  def _make_response(content):
 20      """Build a minimal OpenAI-compatible ChatCompletion response stub."""
 21      message = types.SimpleNamespace(content=content)
 22      choice = types.SimpleNamespace(message=message)
 23      return types.SimpleNamespace(choices=[choice])
 24  
 25  
 26  # ── _extract_relevant_content (line 996) ──────────────────────────────────
 27  
 28  class TestExtractRelevantContentNoneGuard:
 29      """tools/browser_tool.py — _extract_relevant_content()"""
 30  
 31      def test_none_content_falls_back_to_truncated(self):
 32          """When LLM returns None content, should fall back to truncated snapshot."""
 33          with patch("tools.browser_tool.call_llm", return_value=_make_response(None)), \
 34               patch("tools.browser_tool._get_extraction_model", return_value="test-model"):
 35              from tools.browser_tool import _extract_relevant_content
 36              result = _extract_relevant_content("This is a long snapshot text", "find the button")
 37  
 38          assert result is not None
 39          assert isinstance(result, str)
 40          assert len(result) > 0
 41  
 42      def test_normal_content_returned(self):
 43          """Normal string content should pass through."""
 44          with patch("tools.browser_tool.call_llm", return_value=_make_response("Extracted content here")), \
 45               patch("tools.browser_tool._get_extraction_model", return_value="test-model"):
 46              from tools.browser_tool import _extract_relevant_content
 47              result = _extract_relevant_content("snapshot text", "task")
 48  
 49          assert result == "Extracted content here"
 50  
 51      def test_empty_string_content_falls_back(self):
 52          """Empty string content should also fall back to truncated."""
 53          with patch("tools.browser_tool.call_llm", return_value=_make_response("   ")), \
 54               patch("tools.browser_tool._get_extraction_model", return_value="test-model"):
 55              from tools.browser_tool import _extract_relevant_content
 56              result = _extract_relevant_content("This is a long snapshot text", "task")
 57  
 58          assert result is not None
 59          assert len(result) > 0
 60  
 61  
 62  # ── browser_vision (line 1626) ────────────────────────────────────────────
 63  
 64  class TestBrowserVisionNoneGuard:
 65      """tools/browser_tool.py — browser_vision() analysis extraction"""
 66  
 67      def test_none_content_produces_fallback_message(self):
 68          """When LLM returns None content, analysis should have a fallback message."""
 69          response = _make_response(None)
 70          analysis = (response.choices[0].message.content or "").strip()
 71          fallback = analysis or "Vision analysis returned no content."
 72  
 73          assert fallback == "Vision analysis returned no content."
 74  
 75      def test_normal_content_passes_through(self):
 76          """Normal analysis content should pass through unchanged."""
 77          response = _make_response("  The page shows a login form.  ")
 78          analysis = (response.choices[0].message.content or "").strip()
 79          fallback = analysis or "Vision analysis returned no content."
 80  
 81          assert fallback == "The page shows a login form."
 82  
 83  
 84  # ── source line verification ──────────────────────────────────────────────
 85  
 86  class TestBrowserSourceLinesAreGuarded:
 87      """Verify the actual source file has the fix applied."""
 88  
 89      @staticmethod
 90      def _read_file() -> str:
 91          import os
 92          base = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
 93          with open(os.path.join(base, "tools", "browser_tool.py")) as f:
 94              return f.read()
 95  
 96      def test_extract_relevant_content_guarded(self):
 97          src = self._read_file()
 98          # The old unguarded pattern should NOT exist
 99          assert "return response.choices[0].message.content\n" not in src, (
100              "browser_tool.py _extract_relevant_content still has unguarded "
101              ".content return — apply None guard"
102          )
103  
104      def test_browser_vision_guarded(self):
105          src = self._read_file()
106          assert "analysis = response.choices[0].message.content\n" not in src, (
107              "browser_tool.py browser_vision still has unguarded "
108              ".content assignment — apply None guard"
109          )