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 )