diff --git a/agent/display.py b/agent/display.py index 5a16a77d00d..25b1ade44de 100644 --- a/agent/display.py +++ b/agent/display.py @@ -1302,6 +1302,11 @@ def get_cute_tool_message( urls = args.get("urls", []) if urls: url = urls[0] if isinstance(urls, list) else str(urls) + # Handle dict objects from web_search results + if isinstance(url, dict): + url = url.get("url") or url.get("href") or "" + if not isinstance(url, str): + url = str(url) domain = url.replace("https://", "").replace("http://", "").split("/")[0] extra = f" +{len(urls)-1}" if len(urls) > 1 else "" return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}") diff --git a/tests/agent/test_display_todo_progress.py b/tests/agent/test_display_todo_progress.py index d3d804ce14f..4940e32e3ad 100644 --- a/tests/agent/test_display_todo_progress.py +++ b/tests/agent/test_display_todo_progress.py @@ -240,3 +240,78 @@ class TestTodoSkinIntegration: def test_default_skin_prefix(self): msg = get_cute_tool_message("todo", {}, 0.5) assert msg.startswith("┊") + + +class TestWebExtractDisplay: + """get_cute_tool_message for web_extract handles dict objects from web_search results. + + Reproduces and verifies fix for #61693 where web_search result dicts + caused AttributeError when web_extract tried to extract domain names. + """ + + def test_web_extract_with_dict_url_field(self): + """Dict with 'url' field (standard web_search result shape).""" + args = { + "urls": [ + {"url": "https://example.com/path", "title": "Example", "snippet": "..."} + ] + } + msg = get_cute_tool_message("web_extract", args, 0.5) + assert "example.com" in msg + assert "📄" in msg + assert "0.5s" in msg + assert "+" not in msg # only 1 URL + + def test_web_extract_with_dict_href_field(self): + """Dict with 'href' field (alternate key).""" + args = { + "urls": [ + {"href": "http://test.org/page", "title": "Test", "snippet": "..."} + ] + } + msg = get_cute_tool_message("web_extract", args, 0.3) + assert "test.org" in msg + + def test_web_extract_with_dict_no_url_field(self): + """Dict without url/href fields - should not crash.""" + args = { + "urls": [ + {"title": "No URL", "snippet": "Missing url field"} + ] + } + msg = get_cute_tool_message("web_extract", args, 0.2) + # Should handle gracefully, default to empty string domain + assert "📄" in msg + assert "0.2s" in msg + + def test_web_extract_with_multiple_dicts(self): + """Multiple dict URLs show '+N' suffix.""" + args = { + "urls": [ + {"url": "https://example.com/page1"}, + {"url": "https://example.com/page2"}, + {"url": "https://example.com/page3"}, + ] + } + msg = get_cute_tool_message("web_extract", args, 0.6) + assert "example.com" in msg + assert "+2" in msg # 3 URLs total, +2 beyond first + + def test_web_extract_with_mixed_types(self): + """Mix of string URLs and dict objects.""" + args = { + "urls": [ + "https://direct.com/page", + {"url": "https://dict.com/page", "title": "Dict URL"}, + ] + } + msg = get_cute_tool_message("web_extract", args, 0.4) + # First item is a string, so domain should come from it + assert "direct.com" in msg + + def test_web_extract_empty_urls(self): + """Empty urls list - shows 'pages' placeholder.""" + args = {"urls": []} + msg = get_cute_tool_message("web_extract", args, 0.1) + assert "pages" in msg + assert "📄" in msg diff --git a/tests/tools/test_web_tools_dict_urls.py b/tests/tools/test_web_tools_dict_urls.py new file mode 100644 index 00000000000..41cc67ec9f9 --- /dev/null +++ b/tests/tools/test_web_tools_dict_urls.py @@ -0,0 +1,114 @@ +"""Test web_extract_tool handles dict objects from web_search results. + +Reproduces and verifies fix for #61693 where web_search result dicts +caused TypeError when web_extract tried to normalize and validate URLs. +""" +import json +from typing import List + + +def test_web_extract_handles_dict_urls(): + """web_extract_tool should extract URL strings from dict objects. + + When the model passes web_search result dicts (with url/href fields), + web_extract_tool should extract the actual URL strings before processing. + This test verifies the dict-to-string coercion logic works without errors. + + This is a minimal smoke test - it doesn't actually call web_extract_tool + (which requires external services), but verifies the core logic that + dict URLs are converted to strings before regex operations. + """ + # Simulate web_search result dicts + dict_urls: List[dict] = [ + {"url": "https://example.com/page1", "title": "Example 1", "snippet": "..."}, + {"url": "https://example.com/page2", "title": "Example 2", "snippet": "..."}, + {"href": "https://alternate.com/page", "title": "Alternate", "snippet": "..."}, + {"title": "No URL field"}, # Missing url/href + ] + + # This is the coercion logic from the fix in web_tools.py + processed_urls: List[str] = [] + for _url in dict_urls: + # Handle dict objects from web_search results + if isinstance(_url, dict): + _url = _url.get("url") or _url.get("href") or "" + elif not isinstance(_url, str): + _url = str(_url) + processed_urls.append(_url) + + # Verify extraction works + assert processed_urls == [ + "https://example.com/page1", + "https://example.com/page2", + "https://alternate.com/page", + "", # Missing url/href → empty string + ] + + +def test_web_extract_handles_mixed_string_dict_urls(): + """web_extract_tool should handle mix of string URLs and dict objects.""" + mixed_urls = [ + "https://direct.com/page", + {"url": "https://dict.com/page", "title": "Dict URL"}, + {"href": "https://href.com/page"}, + "https://another.com/page", + {"title": "No URL field"}, + ] + + processed_urls: List[str] = [] + for _url in mixed_urls: + if isinstance(_url, dict): + _url = _url.get("url") or _url.get("href") or "" + elif not isinstance(_url, str): + _url = str(_url) + processed_urls.append(_url) + + assert processed_urls == [ + "https://direct.com/page", + "https://dict.com/page", + "https://href.com/page", + "https://another.com/page", + "", + ] + + +def test_web_extract_dict_coercion_preserves_valid_urls(): + """Dict coercion should not break valid URL strings.""" + valid_url = "https://example.com/path?query=value#fragment" + + # String URL should pass through unchanged + if isinstance(valid_url, dict): + processed = valid_url.get("url") or valid_url.get("href") or "" + elif not isinstance(valid_url, str): + processed = str(valid_url) + else: + processed = valid_url + + assert processed == valid_url + + +def test_web_extract_dict_coercion_handles_edge_cases(): + """Test edge cases: non-dict, non-string objects, empty strings.""" + edge_cases = [ + 123, # int + None, # None + True, # bool + "", # empty string + ] + + processed: List[str] = [] + for item in edge_cases: + if isinstance(item, dict): + processed_item = item.get("url") or item.get("href") or "" + elif not isinstance(item, str): + processed_item = str(item) + else: + processed_item = item + processed.append(processed_item) + + # Non-dict, non-string → str() conversion + assert processed[0] == "123" + assert processed[1] == "None" + assert processed[2] == "True" + # Empty string stays empty + assert processed[3] == "" \ No newline at end of file diff --git a/tools/web_tools.py b/tools/web_tools.py index 132d78c7d74..a5366bccb9b 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -763,6 +763,11 @@ async def web_extract_tool( from urllib.parse import unquote normalized_urls: List[str] = [] for _url in urls: + # Handle dict objects from web_search results + if isinstance(_url, dict): + _url = _url.get("url") or _url.get("href") or "" + elif not isinstance(_url, str): + _url = str(_url) normalized_url = normalize_url_for_request(_url) if ( _PREFIX_RE.search(_url)