diff --git a/src/plyngent/cli/display.py b/src/plyngent/cli/display.py index 56a43f8..9e84521 100644 --- a/src/plyngent/cli/display.py +++ b/src/plyngent/cli/display.py @@ -423,6 +423,27 @@ def _fetch_detail(_args_json: str, result: str) -> str: return _pretty_segments((f"({status or 'done'})", _http_status_fg(status))) +# ``web_search`` reports "every source found nothing (or failed)" as an error, so +# the renderer splits that emptiness off the failures that are real errors. +_WEB_SEARCH_EMPTY = "error: the search found nothing" + + +def _web_search_prefix(args_json: str) -> str: + query = _json_str_arg(args_json, "query") or "?" + return _pretty_prefix(f"* Web Search '{_preview(query, _ASK_SUBJECT_PREVIEW)}' ") + + +def _web_search_detail(_args_json: str, result: str) -> str: + """``web_search`` outcome: hit count, nothing found, or a failing source.""" + if result.startswith(_WEB_SEARCH_EMPTY): + return _pretty_segments(("(no results)", "dim")) + if result.startswith("error:"): + return _pretty_segments((f"({result})", "red")) + hits = sum(1 for line in result.splitlines() if line.startswith("- ")) + unit = "hit" if hits == 1 else "hits" + return _pretty_segments((f"({hits} {unit})", None)) + + _MUTATOR_VERBS: dict[str, str] = { "edit_replace": "Edit", "edit_lineno": "Edit", @@ -825,6 +846,7 @@ _PRETTY_LINES: dict[str, PrettyLine] = { "run_argv": PrettyLine(prefix=_run_argv_prefix, detail=_run_argv_detail), "run_argv_batch": PrettyLine(prefix=_run_argv_batch_prefix, detail=_run_argv_batch_detail), "fetch": PrettyLine(prefix=_fetch_prefix, detail=_fetch_detail), + "web_search": PrettyLine(prefix=_web_search_prefix, detail=_web_search_detail), "edit_replace": _mutator_line("edit_replace"), "edit_lineno": _mutator_line("edit_lineno"), "write_file": _mutator_line("write_file"), diff --git a/tests/test_cli/test_display.py b/tests/test_cli/test_display.py index bfd8e21..030ae0a 100644 --- a/tests/test_cli/test_display.py +++ b/tests/test_cli/test_display.py @@ -509,6 +509,42 @@ async def test_pretty_fetch_error(capsys: pytest.CaptureFixture[str]) -> None: assert "* Fetch GET https://example.com (error: fetch failed: boom)" in out +async def test_pretty_web_search(capsys: pytest.CaptureFixture[str]) -> None: + result = ( + "- CPython free-threading — https://example.com/ft\n" + " PEP 703 landed in 3.13\n" + "- Another page — https://example.com/other\n" + ) + call = _pretty_call("web_search", '{"query": "python 3.14 free-threading"}') + await render_events(_aiter([call, _result(result)])) + out = capsys.readouterr().out + assert "* Web Search 'python 3.14 free-threading' (2 hits)" in out + assert "[tool]" not in out + assert "[tool ok]" not in out + + +async def test_pretty_web_search_truncates_the_query(capsys: pytest.CaptureFixture[str]) -> None: + query = "a" * 80 + await render_events( + _aiter([_pretty_call("web_search", f'{{"query": "{query}"}}'), _result("- One — https://example.com\n")]) + ) + out = capsys.readouterr().out + assert f"* Web Search '{'a' * 60}…' (1 hit)" in out + + +async def test_pretty_web_search_no_results(capsys: pytest.CaptureFixture[str]) -> None: + result = "error: the search found nothing (DeepseekSearchBackend: no results; BingSearchBackend: no results)" + await render_events(_aiter([_pretty_call("web_search", '{"query": "zzz"}'), _result(result)])) + out = capsys.readouterr().out + assert "* Web Search 'zzz' (no results)" in out + + +async def test_pretty_web_search_failing_source(capsys: pytest.CaptureFixture[str]) -> None: + await render_events(_aiter([_pretty_call("web_search", '{"query": "x"}'), _result("error: search HTTP 401")])) + out = capsys.readouterr().out + assert "* Web Search 'x' (error: search HTTP 401)" in out + + async def test_pretty_mutators(capsys: pytest.CaptureFixture[str]) -> None: cases = [ ("write_file", '{"path": "src/x.py"}', "wrote 120 characters to src/x.py", "* Write 'src/x.py' (120 chars)"), @@ -862,8 +898,13 @@ async def test_pretty_mcp_tools_use_one_generic_line(capsys: pytest.CaptureFixtu def test_every_builtin_tool_has_a_pretty_line() -> None: """A new builtin tool must ship a renderer, or it silently loses the syntax.""" from plyngent.tools.catalog import default_tool_definitions + from plyngent.tools.net.search import WEB_SEARCH_TOOL_NAME - missing = [d.name for d in default_tool_definitions() if _pretty_line_for(d.name) is None] + names = [d.name for d in default_tool_definitions()] + # The CLI host composes ``web_search`` over the builtins, so a catalog walk + # on its own would let a renderer-less tool ship. + names.append(WEB_SEARCH_TOOL_NAME) + missing = [name for name in names if _pretty_line_for(name) is None] assert missing == []