Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Cyprien <courtot.c@gmail.com>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com>
Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: josephine-delas <57808586+josephine-delas@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Laure Hugo 2026-06-25 16:08:45 +02:00 committed by GitHub
parent 725d3a56ce
commit e607ccbb00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
242 changed files with 7372 additions and 1974 deletions

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import json
from typing import Any, cast
from unittest.mock import AsyncMock, patch
@ -13,8 +14,12 @@ from tests.stubs.fake_connector_registry import FakeConnectorRegistry
from tests.stubs.fake_mcp_registry import FakeMCPRegistry
from vibe.core.config import ConnectorConfig, VibeConfig
from vibe.core.tools.base import BaseToolConfig, ToolError
from vibe.core.tools.connectors import compute_connector_counts
from vibe.core.tools.connectors import (
compute_connector_counts,
connector_registry as connector_registry_module,
)
from vibe.core.tools.connectors.connector_registry import (
_BOOTSTRAP_CACHE_TTL_SECONDS,
ConnectorAuthAction,
ConnectorRegistry,
RemoteTool,
@ -26,6 +31,8 @@ from vibe.core.tools.connectors.connector_registry import (
from vibe.core.tools.manager import ToolManager
from vibe.core.tools.mcp.tools import MCPTool, MCPToolResult
_BOOTSTRAP_CACHE_FILE_NAME = "connector_bootstrap_cache.json"
# ---------------------------------------------------------------------------
# Unit tests for helper functions
# ---------------------------------------------------------------------------
@ -561,6 +568,202 @@ class TestBootstrapDiscovery:
assert tools == {}
assert registry.connector_count == 0
@pytest.mark.asyncio
async def test_fresh_bootstrap_cache_avoids_http(
self, monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
payload = _make_bootstrap_response([
_make_connector_payload(tools=[_make_tool_payload("cached")])
])
with (
patch.object(connector_registry_module.time, "time", return_value=1_000),
respx.mock,
):
respx.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(200, json=payload)
)
await ConnectorRegistry(api_key="test-key").get_tools_async()
with (
patch.object(connector_registry_module.time, "time", return_value=1_100),
respx.mock(assert_all_called=False) as router,
):
route = router.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(500, text="should not be called")
)
tools = await ConnectorRegistry(api_key="test-key").get_tools_async()
assert "connector_wiki_cached" in tools
assert not route.called
@pytest.mark.asyncio
async def test_stale_bootstrap_cache_falls_back_to_http(
self, monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
cached_payload = _make_bootstrap_response([
_make_connector_payload(tools=[_make_tool_payload("cached")])
])
fresh_payload = _make_bootstrap_response([
_make_connector_payload(tools=[_make_tool_payload("fresh")])
])
with (
patch.object(connector_registry_module.time, "time", return_value=1_000),
respx.mock,
):
respx.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(200, json=cached_payload)
)
await ConnectorRegistry(api_key="test-key").get_tools_async()
with (
patch.object(
connector_registry_module.time,
"time",
return_value=1_000 + _BOOTSTRAP_CACHE_TTL_SECONDS + 1,
),
respx.mock,
):
route = respx.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(200, json=fresh_payload)
)
tools = await ConnectorRegistry(api_key="test-key").get_tools_async()
assert route.called
assert "connector_wiki_fresh" in tools
assert "connector_wiki_cached" not in tools
@pytest.mark.asyncio
async def test_malformed_bootstrap_cache_falls_back_to_http(
self, monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
(tmp_path / _BOOTSTRAP_CACHE_FILE_NAME).write_text("{bad toml")
payload = _make_bootstrap_response([
_make_connector_payload(tools=[_make_tool_payload("fresh")])
])
with respx.mock:
route = respx.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(200, json=payload)
)
tools = await ConnectorRegistry(api_key="test-key").get_tools_async()
assert route.called
assert "connector_wiki_fresh" in tools
@pytest.mark.asyncio
async def test_force_refresh_bypasses_fresh_bootstrap_cache(
self, monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
cached_payload = _make_bootstrap_response([
_make_connector_payload(tools=[_make_tool_payload("cached")])
])
fresh_payload = _make_bootstrap_response([
_make_connector_payload(tools=[_make_tool_payload("fresh")])
])
with (
patch.object(connector_registry_module.time, "time", return_value=1_000),
respx.mock,
):
respx.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(200, json=cached_payload)
)
await ConnectorRegistry(api_key="test-key").get_tools_async()
with (
patch.object(connector_registry_module.time, "time", return_value=1_100),
respx.mock,
):
route = respx.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(200, json=fresh_payload)
)
tools = await ConnectorRegistry(api_key="test-key").get_tools_async(
force_refresh=True
)
assert route.called
assert "connector_wiki_fresh" in tools
assert "connector_wiki_cached" not in tools
@pytest.mark.asyncio
async def test_bootstrap_cache_does_not_store_raw_api_key(
self, monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
payload = _make_bootstrap_response([
_make_connector_payload(tools=[_make_tool_payload("cached")])
])
with respx.mock:
respx.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(200, json=payload)
)
await ConnectorRegistry(api_key="secret-test-key").get_tools_async()
cache_text = (tmp_path / _BOOTSTRAP_CACHE_FILE_NAME).read_text()
assert "secret-test-key" not in cache_text
@pytest.mark.asyncio
async def test_bootstrap_cache_stores_only_consumed_connector_fields(
self, monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
payload = _make_bootstrap_response([
_make_connector_payload(
tools=[{**_make_tool_payload("cached"), "secret_extra": "tool-secret"}],
auth_action={"type": "oauth", "url": "https://secret.example.com"},
)
| {
"display_name": "Private display name",
"description": "Private connector description",
"bootstrap_errors": ["private error detail"],
}
])
with respx.mock:
respx.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(200, json=payload)
)
await ConnectorRegistry(api_key="test-key").get_tools_async()
cache_text = (tmp_path / _BOOTSTRAP_CACHE_FILE_NAME).read_text()
assert "Private display name" not in cache_text
assert "Private connector description" not in cache_text
assert "private error detail" not in cache_text
assert "https://secret.example.com" not in cache_text
assert "tool-secret" not in cache_text
cache = json.loads(cache_text)
entry = next(iter(cache.values()))
connector = entry["payload"]["connectors"][0]
assert set(connector) == {"id", "name", "status", "tools", "auth_action"}
assert connector["auth_action"] == {"type": "oauth"}
assert set(connector["tools"][0]) == {"name", "description", "inputSchema"}
@pytest.mark.asyncio
async def test_bootstrap_cache_uses_dedicated_file(
self, monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
payload = _make_bootstrap_response([
_make_connector_payload(tools=[_make_tool_payload("cached")])
])
with respx.mock:
respx.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(200, json=payload)
)
await ConnectorRegistry(api_key="test-key").get_tools_async()
assert (tmp_path / _BOOTSTRAP_CACHE_FILE_NAME).exists()
assert not (tmp_path / "cache.toml").exists()
@respx.mock
@pytest.mark.asyncio
async def test_deduplicates_connector_aliases(self) -> None:
@ -821,6 +1024,51 @@ class TestAuthActionablediscovery:
assert registry.is_connected("linear")
assert registry.get_auth_action("linear") == ConnectorAuthAction.NONE
@pytest.mark.asyncio
async def test_refresh_updates_bootstrap_file_cache(
self, monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
payload = _make_bootstrap_response([
_make_connector_payload(
connector_id="c-1",
name="linear",
is_ready=False,
tools=[],
auth_action={"type": "oauth"},
)
])
with respx.mock:
respx.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(200, json=payload)
)
registry = ConnectorRegistry(api_key="test-key")
await registry.get_tools_async()
refresh_payload = _make_bootstrap_response([
_make_connector_payload(
connector_id="c-1",
name="linear",
tools=[_make_tool_payload("search_issues")],
)
])
with respx.mock:
respx.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(200, json=refresh_payload)
)
await registry.refresh_connector_async("linear")
with respx.mock:
route = respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(500))
warm_registry = ConnectorRegistry(api_key="test-key")
tools = await warm_registry.get_tools_async()
assert not route.called
assert "connector_linear_search_issues" in tools
assert warm_registry.is_connected("linear")
assert warm_registry.get_auth_action("linear") == ConnectorAuthAction.NONE
def test_get_auth_action_unknown_alias_returns_none(self) -> None:
registry = ConnectorRegistry(api_key="test-key")
assert registry.get_auth_action("nobody") == ConnectorAuthAction.NONE

View file

@ -63,6 +63,23 @@ class TestBashGranularPermissions:
assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ASK
@pytest.mark.parametrize(
"command",
[
"python3 << 'EOF'\nprint(42)\nEOF",
"python3 - << 'EOF'\nprint(42)\nEOF",
"python3 <<'PYEOF'\nimport sys\nprint('hello')\nPYEOF",
"python3 < input.txt",
],
)
def test_standalone_denylisted_with_redirect_not_denied(self, command):
bash = self._bash()
result = bash.resolve_permission(BashArgs(command=command))
assert isinstance(result, PermissionContext)
assert result.permission is not ToolPermission.NEVER, (
f"Command with redirect should not be denied: {command!r}"
)
def test_unknown_command_returns_permission_context(self):
bash = self._bash()
result = bash.resolve_permission(BashArgs(command="npm install"))

View file

@ -10,15 +10,8 @@ from vibe.core.tools.manager import NoSuchToolError, ToolManager
@pytest.fixture
def config():
return build_test_vibe_config(
system_prompt_id="tests", include_project_context=False
)
@pytest.fixture
def tool_manager(config):
return ToolManager(lambda: config)
def tool_manager(vibe_config):
return ToolManager(lambda: vibe_config)
def test_returns_default_config_when_no_overrides(tool_manager):
@ -33,11 +26,7 @@ def test_returns_default_config_when_no_overrides(tool_manager):
def test_merges_user_overrides_with_defaults():
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
tools={"bash": {"permission": "always"}},
)
vibe_config = build_test_vibe_config(tools={"bash": {"permission": "always"}})
manager = ToolManager(lambda: vibe_config)
config = manager.get_tool_config("bash")
@ -50,11 +39,7 @@ def test_merges_user_overrides_with_defaults():
def test_preserves_tool_specific_fields_from_overrides():
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
tools={"bash": {"permission": "ask"}},
)
vibe_config = build_test_vibe_config(tools={"bash": {"permission": "ask"}})
vibe_config.tools["bash"]["default_timeout"] = 600
manager = ToolManager(lambda: vibe_config)
@ -73,9 +58,7 @@ def test_falls_back_to_base_config_for_unknown_tool(tool_manager):
def test_partial_override_preserves_tool_defaults():
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
tools={"read": {"sensitive_patterns": ["**/*.key"]}},
tools={"read": {"sensitive_patterns": ["**/*.key"]}}
)
manager = ToolManager(lambda: vibe_config)
@ -89,11 +72,7 @@ def test_partial_override_preserves_tool_defaults():
class TestToolManagerFiltering:
def test_enabled_tools_filters_to_only_enabled(self):
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
enabled_tools=["bash", "grep"],
)
vibe_config = build_test_vibe_config(enabled_tools=["bash", "grep"])
manager = ToolManager(lambda: vibe_config)
tools = manager.available_tools
@ -104,11 +83,7 @@ class TestToolManagerFiltering:
assert "write_file" not in tools
def test_disabled_tools_excludes_disabled(self):
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
disabled_tools=["bash", "write_file"],
)
vibe_config = build_test_vibe_config(disabled_tools=["bash", "write_file"])
manager = ToolManager(lambda: vibe_config)
tools = manager.available_tools
@ -120,8 +95,6 @@ class TestToolManagerFiltering:
def test_enabled_tools_takes_precedence_over_disabled(self):
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
enabled_tools=["bash"],
disabled_tools=["bash"], # Should be ignored
)
@ -133,9 +106,7 @@ class TestToolManagerFiltering:
def test_glob_pattern_matching(self):
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
disabled_tools=["write_*"], # Matches write_file
disabled_tools=["write_*"] # Matches write_file
)
manager = ToolManager(lambda: vibe_config)
@ -145,11 +116,7 @@ class TestToolManagerFiltering:
assert "grep" in tools
def test_regex_pattern_matching(self):
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
enabled_tools=["re:^(bash|grep)$"],
)
vibe_config = build_test_vibe_config(enabled_tools=["re:^(bash|grep)$"])
manager = ToolManager(lambda: vibe_config)
tools = manager.available_tools
@ -158,11 +125,7 @@ class TestToolManagerFiltering:
assert "grep" in tools
def test_get_raises_for_disabled_tool(self):
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
disabled_tools=["bash"],
)
vibe_config = build_test_vibe_config(disabled_tools=["bash"])
manager = ToolManager(lambda: vibe_config)
assert "bash" not in manager.available_tools
@ -170,11 +133,7 @@ class TestToolManagerFiltering:
manager.get("bash")
def test_case_insensitive_matching(self):
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
enabled_tools=["BASH", "GREP"],
)
vibe_config = build_test_vibe_config(enabled_tools=["BASH", "GREP"])
manager = ToolManager(lambda: vibe_config)
tools = manager.available_tools
@ -182,9 +141,7 @@ class TestToolManagerFiltering:
assert "grep" in tools
def test_empty_enabled_tools_returns_all(self):
vibe_config = build_test_vibe_config(
system_prompt_id="tests", include_project_context=False, enabled_tools=[]
)
vibe_config = build_test_vibe_config(enabled_tools=[])
manager = ToolManager(lambda: vibe_config)
tools = manager.available_tools
@ -242,11 +199,7 @@ class FileTool(BaseTool[FileToolArgs, FileToolResult, BaseToolConfig, BaseToolSt
for k in to_remove:
del sys.modules[k]
vibe_config = build_test_vibe_config(
system_prompt_id="tests",
include_project_context=False,
tool_paths=[tool_dir, file_tool],
)
vibe_config = build_test_vibe_config(tool_paths=[tool_dir, file_tool])
manager = ToolManager(lambda: vibe_config)
tools = manager.available_tools
@ -292,11 +245,7 @@ class ConditionalTool(BaseTool[ConditionalToolArgs, ConditionalToolResult, BaseT
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],
)
vibe_config = build_test_vibe_config(tool_paths=[tool_dir])
manager = ToolManager(lambda: vibe_config)
assert "conditional_tool" not in manager.available_tools
@ -310,9 +259,7 @@ class ConditionalTool(BaseTool[ConditionalToolArgs, ConditionalToolResult, BaseT
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
)
vibe_config = build_test_vibe_config()
manager = ToolManager(lambda: vibe_config)
assert "bash" in manager.available_tools
@ -329,9 +276,7 @@ class TestToolManagerModuleReuse:
def test_multiple_managers_share_tool_classes(self):
"""Tool classes should be identical across multiple ToolManager instances."""
vibe_config = build_test_vibe_config(
system_prompt_id="tests", include_project_context=False
)
vibe_config = build_test_vibe_config()
manager1 = ToolManager(lambda: vibe_config)
manager2 = ToolManager(lambda: vibe_config)
@ -347,9 +292,7 @@ class TestToolManagerModuleReuse:
def test_tool_state_classes_are_identical(self):
"""Tool state classes should be identical across managers."""
vibe_config = build_test_vibe_config(
system_prompt_id="tests", include_project_context=False
)
vibe_config = build_test_vibe_config()
manager1 = ToolManager(lambda: vibe_config)
manager2 = ToolManager(lambda: vibe_config)
@ -364,9 +307,7 @@ class TestToolManagerModuleReuse:
def test_tool_args_results_classes_are_identical(self):
"""Tool args and result classes should be identical across managers."""
vibe_config = build_test_vibe_config(
system_prompt_id="tests", include_project_context=False
)
vibe_config = build_test_vibe_config()
manager1 = ToolManager(lambda: vibe_config)
manager2 = ToolManager(lambda: vibe_config)
@ -386,9 +327,7 @@ class TestToolManagerModuleReuse:
This ensures subagents have isolated state (e.g., separate todo lists)
while still sharing class definitions for Pydantic validation.
"""
vibe_config = build_test_vibe_config(
system_prompt_id="tests", include_project_context=False
)
vibe_config = build_test_vibe_config()
manager1 = ToolManager(lambda: vibe_config)
manager2 = ToolManager(lambda: vibe_config)
@ -412,9 +351,7 @@ class TestToolManagerModuleReuse:
def test_class_shared_but_instances_isolated(self):
"""Classes must be shared (for validation) but instances isolated (for state)."""
vibe_config = build_test_vibe_config(
system_prompt_id="tests", include_project_context=False
)
vibe_config = build_test_vibe_config()
manager1 = ToolManager(lambda: vibe_config)
manager2 = ToolManager(lambda: vibe_config)

View file

@ -41,7 +41,7 @@ class MemoryKeyring(KeyringBackend):
def delete_password(self, service: str, username: str) -> None:
if (service, username) not in self.store:
raise keyring.errors.PasswordDeleteError(username)
raise keyring.errors.PasswordDeleteError()
del self.store[(service, username)]

View file

@ -34,9 +34,7 @@ class TestTaskArgs:
class TestTaskToolValidation:
@pytest.fixture
def ctx(self) -> InvokeContext:
config = build_test_vibe_config(
include_project_context=False, include_prompt_detail=False
)
config = build_test_vibe_config()
manager = AgentManager(lambda: config)
return InvokeContext(
tool_call_id="test-call-id",
@ -133,9 +131,7 @@ class TestTaskToolResolvePermission:
class TestTaskToolExecution:
@pytest.fixture
def ctx(self) -> InvokeContext:
config = build_test_vibe_config(
include_project_context=False, include_prompt_detail=False
)
config = build_test_vibe_config()
manager = AgentManager(lambda: config)
return InvokeContext(
tool_call_id="test-call-id",