Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Jean-Baptiste Muscat <jeanbaptiste.muscatdupuis@mistral.ai>
Co-authored-by: JeroenvdV <JeroenvdV@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: Paul Cacheux <paul.cacheux@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: allansimon-mistral <allan.simon@ext.mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Sirieix 2026-05-05 14:40:11 +02:00 committed by GitHub
parent 71f373c60c
commit 4972dd5694
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 2892 additions and 842 deletions

View file

@ -1,11 +1,12 @@
from __future__ import annotations
import os
from typing import cast
from typing import Any, cast
from unittest.mock import AsyncMock, patch
import httpx
import pytest
import respx
from tests.stubs.fake_connector_registry import FakeConnectorRegistry
from tests.stubs.fake_mcp_registry import FakeMCPRegistry
@ -13,6 +14,7 @@ from vibe.core.config import ConnectorConfig, VibeConfig
from vibe.core.tools.base import BaseToolConfig, ToolError
from vibe.core.tools.connectors import CONNECTORS_ENV_VAR
from vibe.core.tools.connectors.connector_registry import (
ConnectorRegistry,
RemoteTool,
_connector_error_message,
_normalize_name,
@ -497,3 +499,206 @@ class TestConnectorDisableFiltering:
)
assert "connector_wiki_search" in tm.available_tools
assert "connector_mail_send" not in tm.available_tools
# ---------------------------------------------------------------------------
# Bootstrap-based discovery (ConnectorRegistry._discover_all via httpx)
# ---------------------------------------------------------------------------
_BOOTSTRAP_URL = "https://api.mistral.ai/v1/connectors/bootstrap"
def _make_bootstrap_response(
connectors: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
return {"connectors": connectors or [], "errors": None}
def _make_connector_payload(
*,
connector_id: str = "conn-1",
name: str = "wiki",
is_ready: bool = True,
tools: list[dict[str, Any]] | None = None,
bootstrap_errors: list[str] | None = None,
) -> dict[str, Any]:
return {
"id": connector_id,
"name": name,
"display_name": name,
"description": name,
"status": {"is_ready": is_ready},
"tools": tools or [],
"bootstrap_errors": bootstrap_errors,
}
def _make_tool_payload(
name: str = "search", description: str = "Search docs"
) -> dict[str, Any]:
return {
"name": name,
"description": description,
"inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}},
}
class TestBootstrapDiscovery:
@respx.mock
@pytest.mark.asyncio
async def test_discovers_tools_from_bootstrap(self) -> None:
payload = _make_bootstrap_response([
_make_connector_payload(
name="wiki",
tools=[_make_tool_payload("search"), _make_tool_payload("read")],
)
])
respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))
registry = ConnectorRegistry(api_key="test-key")
tools = await registry.get_tools_async()
assert "connector_wiki_search" in tools
assert "connector_wiki_read" in tools
assert registry.connector_count == 1
assert registry.is_connected("wiki")
@respx.mock
@pytest.mark.asyncio
async def test_skips_not_ready_connectors(self) -> None:
payload = _make_bootstrap_response([
_make_connector_payload(
name="broken", is_ready=False, bootstrap_errors=["auth failed"]
),
_make_connector_payload(name="healthy", tools=[_make_tool_payload("ping")]),
])
respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))
registry = ConnectorRegistry(api_key="test-key")
tools = await registry.get_tools_async()
assert "connector_healthy_ping" in tools
assert not any("broken" in name for name in tools)
assert not registry.is_connected("broken")
assert registry.is_connected("healthy")
@respx.mock
@pytest.mark.asyncio
async def test_handles_bootstrap_http_error(self) -> None:
respx.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(500, text="Internal Server Error")
)
registry = ConnectorRegistry(api_key="test-key")
tools = await registry.get_tools_async()
assert tools == {}
assert registry.connector_count == 0
@respx.mock
@pytest.mark.asyncio
async def test_deduplicates_connector_aliases(self) -> None:
payload = _make_bootstrap_response([
_make_connector_payload(
connector_id="c-1", name="mcp", tools=[_make_tool_payload("a")]
),
_make_connector_payload(
connector_id="c-2", name="mcp", tools=[_make_tool_payload("b")]
),
])
respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))
registry = ConnectorRegistry(api_key="test-key")
tools = await registry.get_tools_async()
assert "connector_mcp_a" in tools
assert "connector_mcp_2_b" in tools
assert registry.connector_count == 2
@respx.mock
@pytest.mark.asyncio
async def test_skips_connector_without_id(self) -> None:
payload = _make_bootstrap_response([
{"name": "broken", "status": {"is_ready": True}, "tools": []},
_make_connector_payload(name="valid", tools=[_make_tool_payload("ping")]),
])
respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))
registry = ConnectorRegistry(api_key="test-key")
tools = await registry.get_tools_async()
assert "connector_valid_ping" in tools
assert registry.connector_count == 1
@respx.mock
@pytest.mark.asyncio
async def test_empty_connectors_list(self) -> None:
payload = _make_bootstrap_response([])
respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))
registry = ConnectorRegistry(api_key="test-key")
tools = await registry.get_tools_async()
assert tools == {}
assert registry.connector_count == 0
@respx.mock
@pytest.mark.asyncio
async def test_uses_custom_server_url(self) -> None:
custom_url = "https://custom.api.example.com/v1/connectors/bootstrap"
payload = _make_bootstrap_response([
_make_connector_payload(tools=[_make_tool_payload("ping")])
])
respx.get(custom_url).mock(return_value=httpx.Response(200, json=payload))
registry = ConnectorRegistry(
api_key="test-key", server_url="https://custom.api.example.com"
)
tools = await registry.get_tools_async()
assert "connector_wiki_ping" in tools
@respx.mock
@pytest.mark.asyncio
async def test_caches_after_first_call(self) -> None:
payload = _make_bootstrap_response([
_make_connector_payload(tools=[_make_tool_payload("search")])
])
route = respx.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(200, json=payload)
)
registry = ConnectorRegistry(api_key="test-key")
await registry.get_tools_async()
await registry.get_tools_async()
assert route.call_count == 1
@respx.mock
@pytest.mark.asyncio
async def test_refresh_connector_updates_cache(self) -> None:
payload = _make_bootstrap_response([
_make_connector_payload(
connector_id="c-1", name="wiki", tools=[_make_tool_payload("search")]
)
])
respx.get(_BOOTSTRAP_URL).mock(return_value=httpx.Response(200, json=payload))
registry = ConnectorRegistry(api_key="test-key")
await registry.get_tools_async()
# Refresh returns updated tools from a second bootstrap call
refresh_payload = _make_bootstrap_response([
_make_connector_payload(
connector_id="c-1",
name="wiki",
tools=[_make_tool_payload("search"), _make_tool_payload("write")],
)
])
respx.get(_BOOTSTRAP_URL).mock(
return_value=httpx.Response(200, json=refresh_payload)
)
refreshed = await registry.refresh_connector_async("wiki")
assert "connector_wiki_search" in refreshed
assert "connector_wiki_write" in refreshed

View file

@ -16,6 +16,18 @@ from vibe.core.types import Role
async def _wait_for_bash_output_message(
vibe_app: VibeApp, pilot, timeout: float = 1.0
) -> BashOutputMessage:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if message := next(iter(vibe_app.query(BashOutputMessage)), None):
if not message._pending:
return message
await pilot.pause(0.05)
raise TimeoutError(f"BashOutputMessage did not appear within {timeout}s")
async def _wait_for_pending_bash_message(
vibe_app: VibeApp, pilot, timeout: float = 1.0
) -> BashOutputMessage:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
@ -157,3 +169,71 @@ async def test_ui_sends_manual_command_output_to_next_agent_turn() -> None:
assert user_messages[-2].content == injected_message.content
assert user_messages[-2].injected is True
assert user_messages[-1].content == "what did the command print?"
@pytest.mark.asyncio
async def test_ui_shows_command_immediately_in_pending_state(vibe_app: VibeApp) -> None:
"""The command line should appear before the process finishes."""
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!sleep 10"
await pilot.press("enter")
message = await _wait_for_pending_bash_message(vibe_app, pilot)
assert message._pending is True
# command line is rendered
cmd_widget = message.query_one(".bash-command", Static)
assert str(cmd_widget.render()) == "sleep 10"
# no output container yet
assert not list(message.query(".bash-output"))
# clean up: cancel the background task
if vibe_app._bash_task and not vibe_app._bash_task.done():
vibe_app._bash_task.cancel()
@pytest.mark.asyncio
async def test_ui_streams_output_incrementally(vibe_app: VibeApp) -> None:
"""Output should appear as the command produces it, not all at once."""
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
# print lines with a small delay so streaming has a chance to show partial output
chat_input.value = "!bash -lc 'echo first; echo second'"
await pilot.press("enter")
message = await _wait_for_bash_output_message(vibe_app, pilot)
output_widget = message.query_one(".bash-output", Static)
rendered = str(output_widget.render())
assert "first" in rendered
assert "second" in rendered
@pytest.mark.asyncio
async def test_ui_cancels_running_command_on_new_submit(vibe_app: VibeApp) -> None:
"""Submitting new input while a bang command is running should cancel it."""
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!sleep 30"
await pilot.press("enter")
await _wait_for_pending_bash_message(vibe_app, pilot)
assert vibe_app._bash_task is not None
assert not vibe_app._bash_task.done()
# submit a new command which should cancel the first one
chat_input.value = "!echo done"
await pilot.press("enter")
# wait until we have two messages and the second is finished
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
all_msgs = list(vibe_app.query(BashOutputMessage))
if len(all_msgs) == 2 and not all_msgs[1]._pending:
break
await pilot.pause(0.05)
all_msgs = list(vibe_app.query(BashOutputMessage))
assert len(all_msgs) == 2
second = all_msgs[1]
output_widget = second.query_one(".bash-output", Static)
assert str(output_widget.render()) == "done"