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