Co-authored-by: Carlo <carloantonio.patti@mistral.ai>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com>
Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-authored-by: Thomas Kenbeek <thomas.kenbeek@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Quentin 2026-02-27 17:31:58 +01:00 committed by GitHub
parent a560a47ce8
commit 5d2e01a6d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
139 changed files with 7152 additions and 1457 deletions

View file

@ -76,14 +76,14 @@ async def test_decodes_non_utf8_bytes(bash):
assert result.stderr == ""
def test_check_allowlist_denylist():
def test_resolve_permission():
config = BashToolConfig(allowlist=["echo", "pwd"], denylist=["rm"])
bash_tool = Bash(config=config, state=BaseToolState())
allowlisted = bash_tool.check_allowlist_denylist(BashArgs(command="echo hi"))
denylisted = bash_tool.check_allowlist_denylist(BashArgs(command="rm -rf /tmp"))
mixed = bash_tool.check_allowlist_denylist(BashArgs(command="pwd && whoami"))
empty = bash_tool.check_allowlist_denylist(BashArgs(command=""))
allowlisted = bash_tool.resolve_permission(BashArgs(command="echo hi"))
denylisted = bash_tool.resolve_permission(BashArgs(command="rm -rf /tmp"))
mixed = bash_tool.resolve_permission(BashArgs(command="pwd && whoami"))
empty = bash_tool.resolve_permission(BashArgs(command=""))
assert allowlisted is ToolPermission.ALWAYS
assert denylisted is ToolPermission.NEVER

View file

@ -5,21 +5,15 @@ import shutil
import pytest
from tests.mock.utils import collect_result
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.grep import (
Grep,
GrepArgs,
GrepBackend,
GrepState,
GrepToolConfig,
)
from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.builtins.grep import Grep, GrepArgs, GrepBackend, GrepToolConfig
@pytest.fixture
def grep(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = GrepToolConfig()
return Grep(config=config, state=GrepState())
return Grep(config=config, state=BaseToolState())
@pytest.fixture
@ -34,7 +28,7 @@ def grep_gnu_only(tmp_path, monkeypatch):
monkeypatch.setattr("shutil.which", mock_which)
config = GrepToolConfig()
return Grep(config=config, state=GrepState())
return Grep(config=config, state=BaseToolState())
def test_detects_ripgrep_when_available(grep):
@ -143,7 +137,7 @@ async def test_truncates_to_max_matches(grep, tmp_path):
async def test_truncates_to_max_output_bytes(grep, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = GrepToolConfig(max_output_bytes=100)
grep_tool = Grep(config=config, state=GrepState())
grep_tool = Grep(config=config, state=BaseToolState())
(tmp_path / "test.py").write_text("\n".join("x" * 100 for _ in range(10)))
result = await collect_result(grep_tool.run(GrepArgs(pattern="x")))
@ -191,22 +185,11 @@ async def test_ignores_comments_in_vibeignore(grep, tmp_path):
assert result.match_count >= 1
@pytest.mark.asyncio
async def test_tracks_search_history(grep, tmp_path):
(tmp_path / "test.py").write_text("content\n")
await collect_result(grep.run(GrepArgs(pattern="first")))
await collect_result(grep.run(GrepArgs(pattern="second")))
await collect_result(grep.run(GrepArgs(pattern="third")))
assert grep.state.search_history == ["first", "second", "third"]
@pytest.mark.asyncio
async def test_uses_effective_workdir(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
config = GrepToolConfig()
grep_tool = Grep(config=config, state=GrepState())
grep_tool = Grep(config=config, state=BaseToolState())
(tmp_path / "test.py").write_text("match\n")
result = await collect_result(grep_tool.run(GrepArgs(pattern="match", path=".")))

View file

@ -227,6 +227,70 @@ class FileTool(BaseTool[FileToolArgs, FileToolResult, BaseToolConfig, BaseToolSt
assert "file_tool" in tools
class TestToolRuntimeAvailability:
"""Tests for is_available() filtering in ToolManager."""
def test_unavailable_tool_excluded_from_available_tools(
self, tmp_path: Path, monkeypatch
):
"""Tools where is_available() returns False should be excluded."""
import sys
tool_dir = tmp_path / "tools"
tool_dir.mkdir()
(tool_dir / "conditional_tool.py").write_text("""
import os
from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState
from pydantic import BaseModel
class ConditionalToolArgs(BaseModel):
pass
class ConditionalToolResult(BaseModel):
pass
class ConditionalTool(BaseTool[ConditionalToolArgs, ConditionalToolResult, BaseToolConfig, BaseToolState]):
description = "Tool that requires TEST_VAR"
@classmethod
def is_available(cls) -> bool:
return bool(os.getenv("TEST_VAR"))
async def run(self, args, ctx=None):
yield ConditionalToolResult()
""")
to_remove = [k for k in sys.modules if "conditional_tool" in k]
for k in to_remove:
del sys.modules[k]
monkeypatch.delenv("TEST_VAR", raising=False)
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
tool_paths=[tool_dir],
)
manager = ToolManager(lambda: vibe_config)
assert "conditional_tool" not in manager.available_tools
to_remove = [k for k in sys.modules if "conditional_tool" in k]
for k in to_remove:
del sys.modules[k]
monkeypatch.setenv("TEST_VAR", "1")
manager2 = ToolManager(lambda: vibe_config)
assert "conditional_tool" in manager2.available_tools
def test_default_is_available_returns_true(self):
"""Tools without is_available() override should be available."""
vibe_config = build_test_vibe_config(
system_prompt_id="tests", include_project_context=False
)
manager = ToolManager(lambda: vibe_config)
assert "bash" in manager.available_tools
class TestToolManagerModuleReuse:
"""Tests for module reuse across ToolManager instances.

View file

@ -11,6 +11,7 @@ import pytest
from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp
from vibe.core.tools.mcp import (
MCPRegistry,
MCPToolResult,
RemoteTool,
_mcp_stderr_capture,
@ -372,3 +373,181 @@ class TestMCPConfigModels:
# Trailing special chars become underscores which are then stripped
assert config.name == "my_server"
class TestMCPRegistry:
def _make_http_server(
self, name: str, url: str = "http://localhost:8080"
) -> MCPHttp:
return MCPHttp(name=name, transport="http", url=url)
def _make_stdio_server(self, name: str, command: str = "python -m srv") -> MCPStdio:
return MCPStdio(name=name, transport="stdio", command=command)
def test_server_key_is_stable(self):
srv = self._make_http_server("s1")
registry = MCPRegistry()
assert registry._server_key(srv) == registry._server_key(srv)
def test_different_configs_produce_different_keys(self):
registry = MCPRegistry()
s1 = self._make_http_server("s1", url="http://a:1")
s2 = self._make_http_server("s2", url="http://b:2")
assert registry._server_key(s1) != registry._server_key(s2)
def test_get_tools_caches_discovery(self):
registry = MCPRegistry()
srv = self._make_http_server("cached")
remote = RemoteTool(name="tool_a", description="A tool")
proxy = create_mcp_http_proxy_tool_class(
url="http://localhost:8080", remote=remote, alias="cached"
)
key = registry._server_key(srv)
registry._cache[key] = {proxy.get_name(): proxy}
tools = registry.get_tools([srv])
assert "cached_tool_a" in tools
assert tools["cached_tool_a"] is proxy
def test_get_tools_returns_empty_for_no_servers(self):
registry = MCPRegistry()
assert registry.get_tools([]) == {}
def test_clear_drops_cache(self):
registry = MCPRegistry()
srv = self._make_http_server("s")
proxy = create_mcp_http_proxy_tool_class(
url="http://localhost:8080", remote=RemoteTool(name="t"), alias="s"
)
key = registry._server_key(srv)
registry._cache[key] = {proxy.get_name(): proxy}
registry.clear()
assert len(registry._cache) == 0
def test_cache_survives_multiple_get_tools_calls(self):
registry = MCPRegistry()
srv = self._make_http_server("stable")
remote = RemoteTool(name="t1")
proxy = create_mcp_http_proxy_tool_class(
url="http://localhost:8080", remote=remote, alias="stable"
)
key = registry._server_key(srv)
registry._cache[key] = {proxy.get_name(): proxy}
first = registry.get_tools([srv])
second = registry.get_tools([srv])
assert first == second
assert first["stable_t1"] is second["stable_t1"]
def test_disjoint_server_lists_across_agents(self):
registry = MCPRegistry()
srv_x = self._make_http_server("x", url="http://x:1")
srv_y = self._make_http_server("y", url="http://y:2")
proxy_x = create_mcp_http_proxy_tool_class(
url="http://x:1", remote=RemoteTool(name="tx"), alias="x"
)
proxy_y = create_mcp_http_proxy_tool_class(
url="http://y:2", remote=RemoteTool(name="ty"), alias="y"
)
registry._cache[registry._server_key(srv_x)] = {proxy_x.get_name(): proxy_x}
registry._cache[registry._server_key(srv_y)] = {proxy_y.get_name(): proxy_y}
agent_a_tools = registry.get_tools([srv_x])
agent_b_tools = registry.get_tools([srv_y])
assert "x_tx" in agent_a_tools
assert "y_ty" not in agent_a_tools
assert "y_ty" in agent_b_tools
assert "x_tx" not in agent_b_tools
@pytest.mark.asyncio
async def test_discover_http_success(self):
registry = MCPRegistry()
srv = self._make_http_server("demo", url="http://demo:9090")
remote = RemoteTool(name="hello", description="Hi")
with patch(
"vibe.core.tools.mcp.registry.list_tools_http", return_value=[remote]
):
tools = await registry._discover_http(srv)
assert tools is not None
assert len(tools) == 1
name = next(iter(tools))
assert name == "demo_hello"
@pytest.mark.asyncio
async def test_discover_http_failure_returns_none(self):
registry = MCPRegistry()
srv = self._make_http_server("fail", url="http://fail:1")
with patch(
"vibe.core.tools.mcp.registry.list_tools_http",
side_effect=ConnectionError("down"),
):
tools = await registry._discover_http(srv)
assert tools is None
@pytest.mark.asyncio
async def test_discover_stdio_success(self):
registry = MCPRegistry()
srv = self._make_stdio_server("local", command="python -m local_srv")
remote = RemoteTool(name="run", description="Run it")
with patch(
"vibe.core.tools.mcp.registry.list_tools_stdio", return_value=[remote]
):
tools = await registry._discover_stdio(srv)
assert tools is not None
assert len(tools) == 1
name = next(iter(tools))
assert name == "local_run"
@pytest.mark.asyncio
async def test_discover_stdio_failure_returns_none(self):
registry = MCPRegistry()
srv = self._make_stdio_server("broken")
with patch(
"vibe.core.tools.mcp.registry.list_tools_stdio",
side_effect=OSError("no binary"),
):
tools = await registry._discover_stdio(srv)
assert tools is None
def test_get_tools_discovers_only_uncached(self):
registry = MCPRegistry()
cached_srv = self._make_http_server("cached", url="http://c:1")
new_srv = self._make_http_server("new", url="http://n:2")
cached_proxy = create_mcp_http_proxy_tool_class(
url="http://c:1", remote=RemoteTool(name="ct"), alias="cached"
)
registry._cache[registry._server_key(cached_srv)] = {
cached_proxy.get_name(): cached_proxy
}
new_remote = RemoteTool(name="nt")
with patch(
"vibe.core.tools.mcp.registry.list_tools_http", return_value=[new_remote]
):
tools = registry.get_tools([cached_srv, new_srv])
assert "cached_ct" in tools
assert "new_nt" in tools
assert len(registry._cache) == 2

View file

@ -0,0 +1,171 @@
from __future__ import annotations
from unittest.mock import MagicMock
from mcp.types import (
CreateMessageRequestParams,
CreateMessageResult,
ErrorData,
SamplingMessage,
TextContent,
)
import pytest
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.core.tools.mcp_sampling import (
MCPSamplingHandler,
_extract_text_content,
_map_sampling_messages,
)
from vibe.core.types import LLMMessage, Role
def _make_config(model_name: str = "test-model") -> MagicMock:
config = MagicMock()
model = MagicMock()
model.name = model_name
model.temperature = 0.7
config.get_active_model.return_value = model
return config
def _make_params(
messages: list[SamplingMessage] | None = None,
system_prompt: str | None = None,
temperature: float | None = None,
max_tokens: int = 100,
) -> CreateMessageRequestParams:
if messages is None:
messages = [
SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))
]
return CreateMessageRequestParams(
messages=messages,
systemPrompt=system_prompt,
temperature=temperature,
maxTokens=max_tokens,
)
class TestExtractTextContent:
def test_single_text_block(self) -> None:
block = TextContent(type="text", text="hello")
assert _extract_text_content(block) == "hello"
def test_list_of_text_blocks(self) -> None:
blocks = [
TextContent(type="text", text="a"),
TextContent(type="text", text="b"),
]
assert _extract_text_content(blocks) == "a\nb"
def test_unsupported_single_block(self) -> None:
block = MagicMock(type="image", text=None)
assert _extract_text_content(block) == ""
def test_mixed_blocks_skips_non_text(self) -> None:
blocks = [TextContent(type="text", text="keep"), MagicMock(type="image")]
assert _extract_text_content(blocks) == "keep"
class TestMapSamplingMessages:
def test_maps_user_message(self) -> None:
msgs = [
SamplingMessage(role="user", content=TextContent(type="text", text="hi"))
]
result = _map_sampling_messages(msgs)
assert len(result) == 1
assert result[0].role == Role.user
assert result[0].content == "hi"
def test_maps_assistant_message(self) -> None:
msgs = [
SamplingMessage(
role="assistant", content=TextContent(type="text", text="hello")
)
]
result = _map_sampling_messages(msgs)
assert result[0].role == Role.assistant
assert result[0].content == "hello"
def test_maps_multiple_messages(self) -> None:
msgs = [
SamplingMessage(role="user", content=TextContent(type="text", text="q")),
SamplingMessage(
role="assistant", content=TextContent(type="text", text="a")
),
]
result = _map_sampling_messages(msgs)
assert len(result) == 2
assert result[0].role == Role.user
assert result[1].role == Role.assistant
class TestMCPSamplingHandler:
@pytest.mark.asyncio
async def test_basic_text_response(self) -> None:
chunk = mock_llm_chunk(content="LLM says hi")
backend = FakeBackend(chunk)
config = _make_config("my-model")
handler = MCPSamplingHandler(
backend_getter=lambda: backend, config_getter=lambda: config
)
result = await handler(MagicMock(), _make_params())
assert not isinstance(result, Exception)
assert isinstance(result, CreateMessageResult)
assert result.role == "assistant"
assert result.content.type == "text"
assert result.content.text == "LLM says hi"
assert result.model == "my-model"
assert result.stopReason == "endTurn"
@pytest.mark.asyncio
async def test_system_prompt_prepended(self) -> None:
chunk = mock_llm_chunk(content="ok")
backend = FakeBackend(chunk)
config = _make_config()
handler = MCPSamplingHandler(
backend_getter=lambda: backend, config_getter=lambda: config
)
await handler(MagicMock(), _make_params(system_prompt="Be helpful"))
sent_messages: list[LLMMessage] = backend.requests_messages[0]
assert sent_messages[0].role == Role.system
assert sent_messages[0].content == "Be helpful"
assert sent_messages[1].role == Role.user
@pytest.mark.asyncio
async def test_calls_backend_with_messages(self) -> None:
"""Verify the handler forwards messages to the backend."""
chunk = mock_llm_chunk(content="ok")
backend = FakeBackend(chunk)
config = _make_config()
handler = MCPSamplingHandler(
backend_getter=lambda: backend, config_getter=lambda: config
)
await handler(MagicMock(), _make_params())
assert len(backend.requests_messages) == 1
sent = backend.requests_messages[0]
assert len(sent) == 1
assert sent[0].role == Role.user
assert sent[0].content == "Hello"
@pytest.mark.asyncio
async def test_returns_error_on_backend_failure(self) -> None:
backend = FakeBackend(exception_to_raise=RuntimeError("boom"))
config = _make_config()
handler = MCPSamplingHandler(
backend_getter=lambda: backend, config_getter=lambda: config
)
result = await handler(MagicMock(), _make_params())
assert isinstance(result, ErrorData)
assert result.code == -1
assert "boom" in result.message

View file

@ -8,7 +8,7 @@ from tests.conftest import build_test_vibe_config
from tests.mock.utils import collect_result
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import BUILTIN_AGENTS, AgentType
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError, ToolPermission
from vibe.core.tools.builtins.task import Task, TaskArgs, TaskResult, TaskToolConfig
from vibe.core.types import AssistantEvent, LLMMessage, Role
@ -76,6 +76,50 @@ class TestTaskToolValidation:
assert agent.agent_type == AgentType.SUBAGENT
class TestTaskToolResolvePermission:
def test_explore_allowed_by_default(self, task_tool: Task) -> None:
args = TaskArgs(task="do something", agent="explore")
result = task_tool.resolve_permission(args)
assert result == ToolPermission.ALWAYS
def test_unknown_agent_returns_none(self, task_tool: Task) -> None:
args = TaskArgs(task="do something", agent="custom_agent")
result = task_tool.resolve_permission(args)
assert result is None
def test_denylist_takes_precedence(self) -> None:
config = TaskToolConfig(allowlist=["explore"], denylist=["explore"])
tool = Task(config=config, state=BaseToolState())
args = TaskArgs(task="do something", agent="explore")
result = tool.resolve_permission(args)
assert result == ToolPermission.NEVER
def test_glob_pattern_in_allowlist(self) -> None:
config = TaskToolConfig(allowlist=["exp*"])
tool = Task(config=config, state=BaseToolState())
args = TaskArgs(task="do something", agent="explore")
result = tool.resolve_permission(args)
assert result == ToolPermission.ALWAYS
def test_glob_pattern_in_denylist(self) -> None:
config = TaskToolConfig(denylist=["danger*"])
tool = Task(config=config, state=BaseToolState())
args = TaskArgs(task="do something", agent="dangerous_agent")
result = tool.resolve_permission(args)
assert result == ToolPermission.NEVER
def test_empty_lists_returns_none(self) -> None:
config = TaskToolConfig(allowlist=[], denylist=[])
tool = Task(config=config, state=BaseToolState())
args = TaskArgs(task="do something", agent="explore")
result = tool.resolve_permission(args)
assert result is None
def test_default_config_has_explore_in_allowlist(self) -> None:
config = TaskToolConfig()
assert "explore" in config.allowlist
class TestTaskToolExecution:
@pytest.fixture
def ctx(self) -> InvokeContext:

View file

@ -0,0 +1,253 @@
from __future__ import annotations
import httpx
import pytest
import respx
from tests.mock.utils import collect_result
from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.builtins.webfetch import WebFetch, WebFetchArgs, WebFetchConfig
@pytest.fixture
def webfetch():
config = WebFetchConfig()
return WebFetch(config=config, state=BaseToolState())
@pytest.fixture
def webfetch_small():
config = WebFetchConfig(max_content_bytes=100)
return WebFetch(config=config, state=BaseToolState())
@pytest.mark.asyncio
@respx.mock
async def test_bare_domain_gets_https(webfetch):
respx.get("https://example.com").mock(
return_value=httpx.Response(
200, text="ok", headers={"Content-Type": "text/plain"}
)
)
result = await collect_result(webfetch.run(WebFetchArgs(url="example.com")))
assert result.url == "https://example.com"
assert result.content == "ok"
@pytest.mark.asyncio
@respx.mock
async def test_http_url_stays_http(webfetch):
respx.get("http://example.com").mock(
return_value=httpx.Response(
200, text="ok", headers={"Content-Type": "text/plain"}
)
)
result = await collect_result(webfetch.run(WebFetchArgs(url="http://example.com")))
assert result.url == "http://example.com"
@pytest.mark.asyncio
@respx.mock
async def test_https_url_stays_https(webfetch):
respx.get("https://example.com").mock(
return_value=httpx.Response(
200, text="ok", headers={"Content-Type": "text/plain"}
)
)
result = await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
assert result.url == "https://example.com"
@pytest.mark.asyncio
@respx.mock
async def test_protocol_relative_url_normalized(webfetch):
respx.get("https://example.com").mock(
return_value=httpx.Response(
200, text="ok", headers={"Content-Type": "text/plain"}
)
)
result = await collect_result(webfetch.run(WebFetchArgs(url="//example.com")))
assert result.url == "https://example.com"
assert result.content == "ok"
@pytest.mark.asyncio
async def test_ftp_scheme_rejected(webfetch):
with pytest.raises(ToolError, match="Invalid URL scheme: ftp"):
await collect_result(webfetch.run(WebFetchArgs(url="ftp://example.com")))
@pytest.mark.asyncio
async def test_empty_url_rejected(webfetch):
with pytest.raises(ToolError, match="URL cannot be empty"):
await collect_result(webfetch.run(WebFetchArgs(url=" ")))
@pytest.mark.asyncio
@respx.mock
async def test_html_converted_to_markdown(webfetch):
html = "<html><body><h1>Title</h1><p>Hello world</p></body></html>"
respx.get("https://example.com").mock(
return_value=httpx.Response(
200, text=html, headers={"Content-Type": "text/html; charset=utf-8"}
)
)
result = await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
assert "# Title" in result.content
assert "Hello world" in result.content
@pytest.mark.asyncio
@respx.mock
async def test_plain_text_unchanged(webfetch):
respx.get("https://example.com/file.txt").mock(
return_value=httpx.Response(
200, text="just text", headers={"Content-Type": "text/plain"}
)
)
result = await collect_result(
webfetch.run(WebFetchArgs(url="https://example.com/file.txt"))
)
assert result.content == "just text"
@pytest.mark.asyncio
@respx.mock
async def test_scripts_stripped_from_markdown(webfetch):
html = "<html><body><script>alert('xss')</script><style>.x{}</style><p>Clean</p></body></html>"
respx.get("https://example.com").mock(
return_value=httpx.Response(
200, text=html, headers={"Content-Type": "text/html"}
)
)
result = await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
assert "alert" not in result.content
assert ".x{}" not in result.content
assert "Clean" in result.content
@pytest.mark.asyncio
@respx.mock
async def test_cloudflare_retry_on_challenge(webfetch):
route = respx.get("https://example.com")
route.side_effect = [
httpx.Response(403, headers={"cf-mitigated": "challenge"}),
httpx.Response(200, text="success", headers={"Content-Type": "text/plain"}),
]
result = await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
assert result.content == "success"
assert route.call_count == 2
second_request = route.calls[1].request
assert second_request.headers["User-Agent"] == "vibe-cli"
@pytest.mark.asyncio
@respx.mock
async def test_regular_403_not_retried(webfetch):
route = respx.get("https://example.com").mock(
return_value=httpx.Response(403, headers={"Content-Type": "text/plain"})
)
with pytest.raises(ToolError, match="HTTP error 403"):
await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
assert route.call_count == 1
@pytest.mark.asyncio
@respx.mock
async def test_truncates_to_max_bytes_with_disclaimer(webfetch_small):
body = "a" * 200
respx.get("https://example.com").mock(
return_value=httpx.Response(
200, text=body, headers={"Content-Type": "text/plain"}
)
)
result = await collect_result(
webfetch_small.run(WebFetchArgs(url="https://example.com"))
)
assert result.content.startswith("a" * 100)
assert "[Content truncated due to size limit]" in result.content
@pytest.mark.asyncio
@respx.mock
async def test_truncates_html_with_disclaimer(webfetch_small):
html = (
"<html><body><h2>first title</h2>"
+ "x" * 200
+ "<h2>second title</h2></body></html>"
)
respx.get("https://example.com").mock(
return_value=httpx.Response(
200, text=html, headers={"Content-Type": "text/html"}
)
)
result = await collect_result(
webfetch_small.run(WebFetchArgs(url="https://example.com"))
)
assert "## first title" in result.content
assert "## second title" not in result.content
assert "[Content truncated due to size limit]" in result.content
@pytest.mark.asyncio
@respx.mock
async def test_http_404_raises_tool_error(webfetch):
respx.get("https://example.com").mock(return_value=httpx.Response(404))
with pytest.raises(ToolError, match="HTTP error 404"):
await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
@pytest.mark.asyncio
@respx.mock
async def test_http_500_raises_tool_error(webfetch):
respx.get("https://example.com").mock(return_value=httpx.Response(500))
with pytest.raises(ToolError, match="HTTP error 500"):
await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
@pytest.mark.asyncio
@respx.mock
async def test_timeout_raises_tool_error(webfetch):
respx.get("https://example.com").mock(side_effect=httpx.ReadTimeout("timed out"))
with pytest.raises(ToolError, match="Request timed out"):
await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
@pytest.mark.asyncio
@respx.mock
async def test_network_error_raises_tool_error(webfetch):
respx.get("https://example.com").mock(
side_effect=httpx.ConnectError("connection refused")
)
with pytest.raises(ToolError, match="Failed to fetch URL"):
await collect_result(webfetch.run(WebFetchArgs(url="https://example.com")))
@pytest.mark.asyncio
async def test_negative_timeout_rejected(webfetch):
with pytest.raises(ToolError, match="Timeout must be a positive number"):
await collect_result(
webfetch.run(WebFetchArgs(url="https://example.com", timeout=-1))
)
@pytest.mark.asyncio
async def test_zero_timeout_rejected(webfetch):
with pytest.raises(ToolError, match="Timeout must be a positive number"):
await collect_result(
webfetch.run(WebFetchArgs(url="https://example.com", timeout=0))
)
@pytest.mark.asyncio
async def test_over_max_timeout_rejected(webfetch):
with pytest.raises(ToolError, match="Timeout cannot exceed"):
await collect_result(
webfetch.run(WebFetchArgs(url="https://example.com", timeout=999))
)
def test_get_status_text():
assert WebFetch.get_status_text() == "Fetching URL"

View file

@ -0,0 +1,165 @@
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import mistralai
import pytest
from tests.mock.utils import collect_result
from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.builtins.websearch import WebSearch, WebSearchArgs, WebSearchConfig
def _make_response(
content: list | None = None, outputs: list | None = None
) -> mistralai.ConversationResponse:
if outputs is None:
outputs = [mistralai.MessageOutputEntry(content=content or [])]
return mistralai.ConversationResponse(
conversation_id="test",
outputs=outputs,
usage=mistralai.ConversationUsageInfo(
prompt_tokens=10, completion_tokens=20, total_tokens=30
),
)
@pytest.fixture
def websearch(monkeypatch):
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
config = WebSearchConfig()
return WebSearch(config=config, state=BaseToolState())
def test_parse_text_chunks(websearch):
response = _make_response(
content=[mistralai.TextChunk(text="Hello "), mistralai.TextChunk(text="world")]
)
result = websearch._parse_response(response)
assert result.answer == "Hello world"
assert result.sources == []
def test_parse_sources_deduped(websearch):
response = _make_response(
content=[
mistralai.TextChunk(text="Answer"),
mistralai.ToolReferenceChunk(
tool="web_search", title="Site A", url="https://a.com"
),
mistralai.ToolReferenceChunk(
tool="web_search", title="Site A duplicate", url="https://a.com"
),
mistralai.ToolReferenceChunk(
tool="web_search", title="Site B", url="https://b.com"
),
]
)
result = websearch._parse_response(response)
assert result.answer == "Answer"
assert len(result.sources) == 2
assert result.sources[0].url == "https://a.com"
assert result.sources[0].title == "Site A"
assert result.sources[1].url == "https://b.com"
def test_parse_skips_source_without_url(websearch):
response = _make_response(
content=[
mistralai.TextChunk(text="Answer"),
mistralai.ToolReferenceChunk(tool="web_search", title="No URL"),
]
)
result = websearch._parse_response(response)
assert result.sources == []
def test_parse_empty_text_raises(websearch):
response = _make_response(content=[])
with pytest.raises(ToolError, match="No text in agent response"):
websearch._parse_response(response)
def test_parse_whitespace_only_raises(websearch):
response = _make_response(content=[mistralai.TextChunk(text=" ")])
with pytest.raises(ToolError, match="No text in agent response"):
websearch._parse_response(response)
def test_parse_skips_non_message_entries(websearch):
response = _make_response(
outputs=[
mistralai.MessageOutputEntry(content=[mistralai.TextChunk(text="Answer")])
]
)
result = websearch._parse_response(response)
assert result.answer == "Answer"
@pytest.mark.asyncio
async def test_run_missing_api_key(monkeypatch):
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
config = WebSearchConfig()
ws = WebSearch(config=config, state=BaseToolState())
with pytest.raises(ToolError, match="MISTRAL_API_KEY"):
await collect_result(ws.run(WebSearchArgs(query="test")))
@pytest.mark.asyncio
async def test_run_returns_parsed_result(websearch):
response = _make_response(
content=[
mistralai.TextChunk(text="The answer"),
mistralai.ToolReferenceChunk(
tool="web_search", title="Source", url="https://example.com"
),
]
)
mock_start = AsyncMock(return_value=response)
with patch.object(mistralai.Mistral, "beta", create=True) as mock_beta:
mock_beta.conversations.start_async = mock_start
with patch.object(mistralai.Mistral, "__aenter__", return_value=None):
with patch.object(mistralai.Mistral, "__aexit__", return_value=None):
result = await collect_result(
websearch.run(WebSearchArgs(query="test query"))
)
assert result.answer == "The answer"
assert len(result.sources) == 1
assert result.sources[0].url == "https://example.com"
@pytest.mark.asyncio
async def test_run_sdk_error_wrapped(websearch):
from unittest.mock import Mock
import httpx
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 500
mock_response.text = "error"
mock_response.headers = httpx.Headers({"content-type": "application/json"})
with patch.object(mistralai.Mistral, "beta", create=True) as mock_beta:
mock_beta.conversations.start_async = AsyncMock(
side_effect=mistralai.SDKError("API failed", mock_response)
)
with patch.object(mistralai.Mistral, "__aenter__", return_value=None):
with patch.object(mistralai.Mistral, "__aexit__", return_value=None):
with pytest.raises(ToolError, match="Mistral API error"):
await collect_result(websearch.run(WebSearchArgs(query="test")))
def test_is_available_with_key(monkeypatch):
monkeypatch.setenv("MISTRAL_API_KEY", "key")
assert WebSearch.is_available() is True
def test_is_available_without_key(monkeypatch):
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
assert WebSearch.is_available() is False
def test_get_status_text():
assert WebSearch.get_status_text() == "Searching the web"