Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Peter Evers <peter.evers@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@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: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-05-29 16:17:54 +02:00 committed by GitHub
parent 198277af3f
commit ad0d5c9520
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 969 additions and 461 deletions

View file

@ -139,7 +139,7 @@ class TestACPAuthStatus:
assert response == {
"authenticated": True,
"authState": AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV.value,
"signOutAvailable": False,
"signOutAvailable": True,
}
@pytest.mark.asyncio
@ -213,22 +213,23 @@ class TestACPAuthSignOut:
assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key"
@pytest.mark.asyncio
async def test_refuses_combined_dotenv_and_process_env_key(
async def test_removes_dotenv_key_and_restores_process_env_key(
self, config_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(DEFAULT_MISTRAL_API_ENV_KEY, "process-key")
write_env_file(config_dir, f"{DEFAULT_MISTRAL_API_ENV_KEY}=file-key\n")
acp_agent_loop = build_acp_agent_loop(build_mistral_provider())
with pytest.raises(
InvalidRequestError,
match=AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV.value,
):
await acp_agent_loop.ext_method("auth/signOut", {})
response = await acp_agent_loop.ext_method("auth/signOut", {})
assert dotenv_values(config_dir / ".env")[DEFAULT_MISTRAL_API_ENV_KEY] == (
"file-key"
)
assert response == {}
assert DEFAULT_MISTRAL_API_ENV_KEY not in dotenv_values(config_dir / ".env")
assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key"
assert await acp_agent_loop.ext_method("auth/status", {}) == {
"authenticated": True,
"authState": AuthStateKind.PROCESS_ENV.value,
"signOutAvailable": False,
}
@pytest.mark.asyncio
async def test_refuses_unsupported_provider_key(

View file

@ -62,7 +62,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.12.1"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.13.0"
)
assert response.auth_methods is not None
@ -94,7 +94,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.12.1"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.13.0"
)
assert response.auth_methods is not None

View file

@ -315,7 +315,7 @@ class TestAdapterPrepareRequest:
payload = json.loads(req.body)
assert payload["model"] == "claude-sonnet-4-20250514"
assert payload["max_tokens"] == 1024
assert payload["temperature"] == 0.5
assert "temperature" not in payload
assert req.endpoint == "/v1/messages"
assert req.headers["anthropic-version"] == "2023-06-01"
@ -392,9 +392,10 @@ class TestAdapterPrepareRequest:
thinking="medium",
)
payload = json.loads(req.body)
assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10000}
assert payload["thinking"] == {"type": "adaptive", "display": "summarized"}
assert payload["output_config"] == {"effort": "medium"}
assert payload["max_tokens"] == 1024
assert payload["temperature"] == 1
assert "temperature" not in payload
def test_system_cached(self, adapter, provider):
messages = [
@ -439,12 +440,8 @@ class TestAdapterPrepareRequest:
assert len(payload["tools"]) == 1
assert payload["tools"][0]["name"] == "test_tool"
@pytest.mark.parametrize(
"level,expected_budget", [("low", 1024), ("medium", 10_000), ("high", 32_000)]
)
def test_thinking_levels_budget_model(
self, adapter, provider, level, expected_budget
):
@pytest.mark.parametrize("level", ["low", "medium", "high", "max"])
def test_thinking_levels(self, adapter, provider, level):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name="claude-sonnet-4-20250514",
@ -458,59 +455,12 @@ class TestAdapterPrepareRequest:
thinking=level,
)
payload = json.loads(req.body)
assert payload["thinking"] == {
"type": "enabled",
"budget_tokens": expected_budget,
}
assert payload["temperature"] == 1
assert payload["max_tokens"] == expected_budget + 8192
@pytest.mark.parametrize(
"model_name", ["claude-opus-4-6-20260101", "claude-opus-4-7-20260418"]
)
@pytest.mark.parametrize("level", ["low", "medium", "high"])
def test_thinking_levels_adaptive_model(self, adapter, provider, model_name, level):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name=model_name,
messages=messages,
temperature=0.5,
tools=None,
max_tokens=None,
tool_choice=None,
enable_streaming=False,
provider=provider,
thinking=level,
)
payload = json.loads(req.body)
assert payload["thinking"] == {"type": "adaptive", "display": "summarized"}
assert payload["output_config"] == {"effort": level}
if "opus-4-7" in model_name:
assert "temperature" not in payload
else:
assert payload["temperature"] == 1
assert "temperature" not in payload
assert payload["max_tokens"] == 32_768
@pytest.mark.parametrize("thinking_level", ["off", "low", "medium", "high"])
def test_temperature_omitted_for_deprecated_model(
self, adapter, provider, thinking_level
):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name="claude-opus-4-7-20260418",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=None,
tool_choice=None,
enable_streaming=False,
provider=provider,
thinking=thinking_level,
)
payload = json.loads(req.body)
assert "temperature" not in payload
def test_history_forced_thinking_budget_model(self, adapter, provider):
def test_history_forced_thinking(self, adapter, provider):
messages = [
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(
@ -532,34 +482,9 @@ class TestAdapterPrepareRequest:
provider=provider,
)
payload = json.loads(req.body)
assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10_000}
assert payload["temperature"] == 1
assert payload["max_tokens"] == 18_192
def test_history_forced_thinking_adaptive_model(self, adapter, provider):
messages = [
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(
role=Role.assistant,
content="Answer",
reasoning_content="thinking...",
reasoning_signature="sig",
),
LLMMessage(role=Role.user, content="Follow up"),
]
req = adapter.prepare_request(
model_name="claude-opus-4-6-20260101",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=None,
tool_choice=None,
enable_streaming=False,
provider=provider,
)
payload = json.loads(req.body)
assert payload["thinking"] == {"type": "adaptive", "display": "summarized"}
assert payload["output_config"] == {"effort": "medium"}
assert "temperature" not in payload
assert payload["max_tokens"] == 32_768

View file

@ -0,0 +1,140 @@
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from ipaddress import IPv4Address
from pathlib import Path
import ssl
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
import pytest
from tests.e2e.mock_server import StreamingMockServer
from vibe.core.config import ModelConfig, ProviderConfig
from vibe.core.llm.backend.generic import GenericBackend
from vibe.core.types import Backend, LLMMessage, Role
from vibe.core.utils import build_ssl_context, configure_ssl_context
@dataclass(frozen=True)
class _TLSMaterial:
ca_file: str
cert_file: str
key_file: str
@dataclass(frozen=True)
class _HttpsStreamingMockServer:
server: StreamingMockServer
ca_file: str
@pytest.fixture
def https_streaming_mock_server(tmp_path: Path) -> Iterator[_HttpsStreamingMockServer]:
tls = _write_tls_material(tmp_path)
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ssl_context.load_cert_chain(certfile=tls.cert_file, keyfile=tls.key_file)
server = StreamingMockServer(ssl_context=ssl_context)
server.start()
try:
yield _HttpsStreamingMockServer(server=server, ca_file=tls.ca_file)
finally:
server.stop()
@pytest.mark.asyncio
async def test_generic_backend_streaming_uses_ssl_cert_file(
monkeypatch: pytest.MonkeyPatch,
https_streaming_mock_server: _HttpsStreamingMockServer,
) -> None:
monkeypatch.setenv("SSL_CERT_FILE", https_streaming_mock_server.ca_file)
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
configure_ssl_context(enable_system_trust_store=False)
build_ssl_context.cache_clear()
chunks = []
try:
provider = ProviderConfig(
name="mock-provider",
api_base=https_streaming_mock_server.server.api_base,
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.GENERIC,
)
model = ModelConfig(
name="mock-model", provider="mock-provider", alias="mock-model"
)
async with GenericBackend(provider=provider, timeout=5.0) as backend:
chunks = [
chunk
async for chunk in backend.complete_streaming(
model=model, messages=[LLMMessage(role=Role.user, content="Greet")]
)
]
finally:
configure_ssl_context(enable_system_trust_store=False)
build_ssl_context.cache_clear()
content = "".join(chunk.message.content or "" for chunk in chunks)
request_payload = https_streaming_mock_server.server.requests[-1]
assert content == "Hello from mock server"
assert request_payload.get("stream") is True
assert request_payload.get("model") == "mock-model"
def _write_tls_material(tmp_path: Path) -> _TLSMaterial:
now = datetime.now(UTC)
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Vibe test CA")])
ca_cert = (
x509.CertificateBuilder()
.subject_name(ca_name)
.issuer_name(ca_name)
.public_key(ca_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=1))
.not_valid_after(now + timedelta(days=1))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.sign(ca_key, hashes.SHA256())
)
server_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
server_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "127.0.0.1")])
server_cert = (
x509.CertificateBuilder()
.subject_name(server_name)
.issuer_name(ca_cert.subject)
.public_key(server_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=1))
.not_valid_after(now + timedelta(days=1))
.add_extension(
x509.SubjectAlternativeName([x509.IPAddress(IPv4Address("127.0.0.1"))]),
critical=False,
)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(
x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False
)
.sign(ca_key, hashes.SHA256())
)
ca_file = tmp_path / "ca.pem"
cert_file = tmp_path / "server.pem"
key_file = tmp_path / "server.key"
ca_file.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
cert_file.write_bytes(server_cert.public_bytes(serialization.Encoding.PEM))
key_file.write_bytes(
server_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
)
return _TLSMaterial(
ca_file=str(ca_file), cert_file=str(cert_file), key_file=str(key_file)
)

View file

@ -91,7 +91,7 @@ class TestPrepareRequest:
assert payload["anthropic_version"] == "vertex-2023-10-16"
assert "model" not in payload
assert payload["max_tokens"] == 1024
assert payload["temperature"] == 0.5
assert "temperature" not in payload
assert req.headers["Authorization"] == "Bearer fake-token"
assert req.headers["anthropic-beta"] == adapter.BETA_FEATURES
assert "rawPredict" in req.endpoint
@ -147,9 +147,10 @@ class TestPrepareRequest:
)
payload = json.loads(req.body)
assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10000}
assert payload["thinking"] == {"type": "adaptive", "display": "summarized"}
assert payload["output_config"] == {"effort": "medium"}
assert payload["max_tokens"] == 1024
assert payload["temperature"] == 1
assert "temperature" not in payload
def test_with_tools(self, adapter, provider):
messages = [LLMMessage(role=Role.user, content="Hello")]

View file

@ -11,6 +11,7 @@ from vibe.cli.plan_offer.decide_plan_offer import (
PlanInfo,
WhoAmIPlanType,
decide_plan_offer,
plan_offer_cta,
resolve_api_key_for_plan,
)
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
@ -174,6 +175,47 @@ def test_resolve_api_key_for_plan_with_missing_env_var() -> None:
environ["MISTRAL_API_KEY"] = previous_api_key
@pytest.mark.parametrize(
("plan_info", "expected_cta"),
[
(
PlanInfo(
plan_type=WhoAmIPlanType.CHAT,
plan_name="INDIVIDUAL",
prompt_switching_to_pro_plan=True,
),
"### Switch to your [Vibe Pro API key](https://chat.mistral.ai/code/extensions?focus=key)",
),
(
PlanInfo(
plan_type=WhoAmIPlanType.API,
plan_name="FREE",
prompt_switching_to_pro_plan=False,
),
"### Unlock more with Vibe - [Upgrade to Vibe Pro](https://chat.mistral.ai/code/extensions?focus=key)",
),
],
ids=["switch-to-vibe-pro-key", "upgrade-to-vibe-pro"],
)
def test_plan_offer_cta_routes_users_to_vibe_api_key_extensions(
plan_info: PlanInfo, expected_cta: str
) -> None:
assert plan_offer_cta(plan_info) == expected_cta
def test_plan_offer_cta_uses_configured_vibe_url() -> None:
plan_info = PlanInfo(
plan_type=WhoAmIPlanType.CHAT,
plan_name="INDIVIDUAL",
prompt_switching_to_pro_plan=True,
)
assert (
plan_offer_cta(plan_info, vibe_base_url="https://vibe.example.com/")
== "### Switch to your [Vibe Pro API key](https://vibe.example.com/code/extensions?focus=key)"
)
@pytest.mark.parametrize(
("response", "expected"),
[

View file

@ -12,14 +12,12 @@ class TestCompactMessage:
message.post_message = MagicMock()
message.set_complete(
old_tokens=177_017,
new_tokens=23_263,
old_session_id="11111111-1111-1111-1111-111111111111",
new_session_id="22222222-2222-2222-2222-222222222222",
)
assert message.get_content() == (
"Compaction complete: 177,017 → 23,263 tokens (-87.%)\n"
"Compaction completed.\n"
"session: "
f"{shorten_session_id('11111111-1111-1111-1111-111111111111')} "
"(before compaction) → "

View file

@ -5,13 +5,15 @@ from unittest.mock import MagicMock, patch
import pytest
from vibe.core.utils.http import build_ssl_context
from vibe.core.utils.http import build_ssl_context, configure_ssl_context
@pytest.fixture(autouse=True)
def _clear_ssl_cache():
configure_ssl_context(enable_system_trust_store=False)
build_ssl_context.cache_clear()
yield
configure_ssl_context(enable_system_trust_store=False)
build_ssl_context.cache_clear()
@ -20,6 +22,42 @@ def test_build_ssl_context_returns_ssl_context():
assert isinstance(ctx, ssl.SSLContext)
def test_build_ssl_context_uses_certifi_by_default(monkeypatch):
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
mock_ctx = MagicMock(spec=ssl.SSLContext)
with (
patch("vibe.core.utils.http.certifi.where", return_value="/certifi.pem"),
patch(
"vibe.core.utils.http.ssl.create_default_context", return_value=mock_ctx
) as create_default_context,
):
build_ssl_context()
create_default_context.assert_called_once_with(cafile="/certifi.pem")
def test_build_ssl_context_uses_system_trust_store_when_configured(monkeypatch):
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
configure_ssl_context(enable_system_trust_store=True)
mock_ctx = MagicMock(spec=ssl.SSLContext)
with (
patch(
"vibe.core.utils.http.truststore.SSLContext", return_value=mock_ctx
) as truststore_context,
patch(
"vibe.core.utils.http.ssl.create_default_context"
) as create_default_context,
):
build_ssl_context()
truststore_context.assert_called_once_with(ssl.PROTOCOL_TLS_CLIENT)
create_default_context.assert_not_called()
def test_build_ssl_context_loads_custom_cert_file(monkeypatch, tmp_path):
cert_file = tmp_path / "custom.pem"
cert_file.write_text("dummy")
@ -37,6 +75,24 @@ def test_build_ssl_context_loads_custom_cert_file(monkeypatch, tmp_path):
)
def test_build_ssl_context_loads_custom_cert_file_with_system_trust_store(
monkeypatch, tmp_path
):
cert_file = tmp_path / "custom.pem"
cert_file.write_text("dummy")
monkeypatch.setenv("SSL_CERT_FILE", str(cert_file))
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
configure_ssl_context(enable_system_trust_store=True)
mock_ctx = MagicMock(spec=ssl.SSLContext)
with patch("vibe.core.utils.http.truststore.SSLContext", return_value=mock_ctx):
build_ssl_context()
mock_ctx.load_verify_locations.assert_called_once_with(
cafile=str(cert_file), capath=None
)
def test_build_ssl_context_loads_custom_cert_dir(monkeypatch, tmp_path):
cert_dir = tmp_path / "certs"
cert_dir.mkdir()

View file

@ -2,8 +2,10 @@ from __future__ import annotations
import json
from pathlib import Path
import ssl
import tomllib
from typing import Literal, TypedDict, Unpack
from unittest.mock import MagicMock, patch
import pytest
import tomli_w
@ -23,6 +25,7 @@ from vibe.core.config.harness_files import (
from vibe.core.paths import VIBE_HOME
from vibe.core.trusted_folders import trusted_folders_manager
from vibe.core.types import Backend
from vibe.core.utils.http import build_ssl_context, configure_ssl_context
from vibe.setup.onboarding.context import OnboardingContext
@ -198,6 +201,73 @@ class TestSaveUpdates:
assert result == {"tools": {"bash": {"default_timeout": 600}}}
class TestSystemTrustStoreConfig:
def test_load_configures_ssl_context_from_toml(self, config_dir: Path) -> None:
config_file = config_dir / "config.toml"
with config_file.open("rb") as f:
data = tomllib.load(f)
data["enable_system_trust_store"] = True
with config_file.open("wb") as f:
tomli_w.dump(data, f)
with patch("vibe.core.config._settings.configure_ssl_context") as configure:
config = VibeConfig.load()
assert config.enable_system_trust_store is True
configure.assert_called_once_with(enable_system_trust_store=True)
def test_load_configures_ssl_context_from_env(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_ENABLE_SYSTEM_TRUST_STORE", "true")
with patch("vibe.core.config._settings.configure_ssl_context") as configure:
config = VibeConfig.load()
assert config.enable_system_trust_store is True
configure.assert_called_once_with(enable_system_trust_store=True)
def test_load_clears_cached_ssl_context_when_setting_changes(
self, config_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
configure_ssl_context(enable_system_trust_store=False)
build_ssl_context.cache_clear()
try:
config_file = config_dir / "config.toml"
with config_file.open("rb") as f:
data = tomllib.load(f)
data["enable_system_trust_store"] = False
with config_file.open("wb") as f:
tomli_w.dump(data, f)
default_ctx = MagicMock(spec=ssl.SSLContext)
with patch(
"vibe.core.utils.http.ssl.create_default_context",
return_value=default_ctx,
):
VibeConfig.load()
assert build_ssl_context() is default_ctx
data["enable_system_trust_store"] = True
with config_file.open("wb") as f:
tomli_w.dump(data, f)
truststore_ctx = MagicMock(spec=ssl.SSLContext)
with patch(
"vibe.core.utils.http.truststore.SSLContext",
return_value=truststore_ctx,
):
VibeConfig.load()
assert build_ssl_context() is truststore_ctx
finally:
configure_ssl_context(enable_system_trust_store=False)
build_ssl_context.cache_clear()
class TestSetThinking:
def test_persists_thinking_to_toml(self, config_dir: Path) -> None:
config_file = config_dir / "config.toml"
@ -821,6 +891,7 @@ class TestOnboardingContextResolution:
def test_load_uses_env_overrides(self, monkeypatch: pytest.MonkeyPatch) -> None:
reset_harness_files_manager()
monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model")
monkeypatch.setenv("VIBE_VIBE_BASE_URL", "https://env-vibe.example.com")
monkeypatch.setenv(
"VIBE_PROVIDERS",
json.dumps([
@ -842,6 +913,7 @@ class TestOnboardingContextResolution:
assert context.provider.name == "env-provider"
assert context.provider.api_key_env_var == "ENV_API_KEY"
assert context.vibe_base_url == "https://env-vibe.example.com"
def test_load_prefers_explicit_overrides_over_toml_and_env(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch

View file

@ -331,17 +331,13 @@ class TestTelemetryClient:
client = TelemetryClient(config_getter=lambda: config)
client.send_auto_compact_triggered(
nb_context_tokens_before=123,
nb_context_tokens_after=45,
auto_compact_threshold=100,
status="success",
nb_context_tokens_before=123, auto_compact_threshold=100, status="success"
)
assert len(telemetry_events) == 1
assert telemetry_events[0]["event_name"] == "vibe.auto_compact_triggered"
assert telemetry_events[0]["properties"] == {
"nb_context_tokens_before": 123,
"nb_context_tokens_after": 45,
"auto_compact_threshold": 100,
"status": "success",
}
@ -674,7 +670,6 @@ class TestTelemetryClient:
client.send_auto_compact_triggered(
nb_context_tokens_before=123,
nb_context_tokens_after=45,
auto_compact_threshold=100,
status="success",
session_id="original-session-id",

View file

@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from vibe.core.utils import compact_reduction_display, get_server_url_from_api_base
from vibe.core.utils import compact_complete_display, get_server_url_from_api_base
import vibe.core.utils.io as io_utils
from vibe.core.utils.io import decode_safe, read_safe, read_safe_async
@ -23,22 +23,18 @@ def test_get_server_url_from_api_base(api_base, expected):
assert get_server_url_from_api_base(api_base) == expected
class TestCompactReductionDisplay:
class TestCompactCompleteDisplay:
def test_includes_session_ids_when_available(self) -> None:
assert compact_reduction_display(
177_017,
23_263,
assert compact_complete_display(
old_session_id="11111111-1111-1111-1111-111111111111",
new_session_id="22222222-2222-2222-2222-222222222222",
) == (
"Compaction complete: 177,017 → 23,263 tokens (-87.%)\n"
"Compaction completed.\n"
"session: 11111111 (before compaction) → 22222222 (after compaction)"
)
def test_returns_base_message_without_session_ids(self) -> None:
assert compact_reduction_display(177_017, 23_263) == (
"Compaction complete: 177,017 → 23,263 tokens (-87.%)"
)
assert compact_complete_display() == "Compaction completed."
class TestReadSafe:

View file

@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Callable
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import ssl
import threading
import time
from typing import TypedDict, cast
@ -84,11 +85,21 @@ class StreamingMockServer:
),
]
def __init__(self, *, chunk_factory: ChunkFactory | None = None) -> None:
def __init__(
self,
*,
chunk_factory: ChunkFactory | None = None,
ssl_context: ssl.SSLContext | None = None,
) -> None:
self.requests: list[ChatCompletionsRequestPayload] = []
self._lock = threading.Lock()
self._chunk_factory = chunk_factory
self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._build_handler())
self._scheme = "https" if ssl_context is not None else "http"
if ssl_context is not None:
self._server.socket = ssl_context.wrap_socket(
self._server.socket, server_side=True
)
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
def _build_handler(self) -> type[BaseHTTPRequestHandler]:
@ -138,7 +149,7 @@ class StreamingMockServer:
@property
def api_base(self) -> str:
return f"http://127.0.0.1:{self._server.server_port}/v1"
return f"{self._scheme}://127.0.0.1:{self._server.server_port}/v1"
def start(self) -> None:
self._thread.start()

View file

@ -12,7 +12,7 @@ from textual.geometry import Size
from textual.pilot import Pilot
from textual.screen import Screen
from textual.widget import Widget
from textual.widgets import Input
from textual.widgets import Input, Link
from tests.browser_sign_in.stubs import build_browser_sign_in_service_factory
from tests.conftest import build_test_vibe_config
@ -70,6 +70,7 @@ def _build_onboarding_config(
api_key_env_var: str = "MISTRAL_API_KEY",
browser_auth_base_url: str | None = None,
browser_auth_api_base_url: str | None = None,
vibe_base_url: str = "https://chat.mistral.ai",
) -> VibeConfig:
provider = ProviderConfig(
name=provider_name,
@ -84,7 +85,9 @@ def _build_onboarding_config(
provider=model_provider or provider_name,
alias="devstral-2",
)
return build_test_vibe_config(providers=[provider], models=[model])
return build_test_vibe_config(
providers=[provider], models=[model], vibe_base_url=vibe_base_url
)
def _build_browser_onboarding_app(
@ -236,6 +239,12 @@ async def _show_browser_sign_in(pilot: Pilot) -> None:
await _wait_for(lambda: isinstance(pilot.app.screen, BrowserSignInScreen), pilot)
async def _show_manual_api_key_screen(pilot: Pilot) -> None:
await _show_auth_method(pilot)
await pilot.press("down", "enter")
await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot)
@pytest.mark.asyncio
async def test_ui_keeps_manual_flow_when_browser_sign_in_is_unsupported() -> None:
app = OnboardingApp(
@ -892,6 +901,20 @@ def test_api_key_screen_uses_mistral_fallback_for_context_without_env_key(
assert screen.provider.api_key_env_var == "MISTRAL_API_KEY"
@pytest.mark.asyncio
async def test_ui_manual_api_key_screen_uses_configured_vibe_url() -> None:
app = OnboardingApp(
config=_build_onboarding_config(vibe_base_url="https://vibe.example.com/")
)
async with app.run_test() as pilot:
await _show_manual_api_key_screen(pilot)
provider_link = app.screen.query_one("#api-key-provider-link", Link)
assert provider_link.url == "https://vibe.example.com/code/extensions?focus=key"
def test_persist_api_key_returns_save_error_for_invalid_env_var_name() -> None:
provider = ProviderConfig(
name="custom", api_base="https://custom.example/v1", api_key_env_var="BAD=NAME"

View file

@ -116,7 +116,7 @@ def test_assess_vibe_home_env_file_overrides_process_env_when_both_sources_exist
assert state == AuthState(
kind=AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV,
can_use_active_provider=True,
sign_out_available=False,
sign_out_available=True,
env_key=DEFAULT_MISTRAL_API_ENV_KEY,
)

View file

@ -129,13 +129,13 @@
<text class="terminal-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="48.8" y="93.2" textLength="134.2" clip-path="url(#terminal-line-3)">&#160;⢠⢢&#160;&#160;&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="48.8" y="117.6" textLength="134.2" clip-path="url(#terminal-line-4)">&#160;⢸⣀⡔⢉⠱⣃⡐⣔</text><text class="terminal-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="48.8" y="142" textLength="134.2" clip-path="url(#terminal-line-5)">&#160;⠈⠒⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="48.8" y="93.2" textLength="134.2" clip-path="url(#terminal-line-3)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="48.8" y="117.6" textLength="134.2" clip-path="url(#terminal-line-4)">&#160;⠸⣀⡔⢉⠱⣃⡢⣂</text><text class="terminal-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="48.8" y="142" textLength="134.2" clip-path="url(#terminal-line-5)">&#160;&#160;⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r2" x="48.8" y="166.4" textLength="292.8" clip-path="url(#terminal-line-6)">Get&#160;your&#160;Mistral&#160;API&#160;key</text><text class="terminal-r1" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r3" x="48.8" y="190.8" textLength="597.8" clip-path="url(#terminal-line-7)">Visit&#160;AI&#160;Studio&#160;to&#160;generate&#160;or&#160;copy&#160;your&#160;Vibe&#160;key</text><text class="terminal-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r3" x="48.8" y="190.8" textLength="634.4" clip-path="url(#terminal-line-7)">Visit&#160;Mistral&#160;Vibe&#160;to&#160;generate&#160;or&#160;copy&#160;your&#160;Vibe&#160;key</text><text class="terminal-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r4" x="48.8" y="239.6" textLength="488" clip-path="url(#terminal-line-9)">https://console.mistral.ai/codestral/cli</text><text class="terminal-r1" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r4" x="48.8" y="239.6" textLength="597.8" clip-path="url(#terminal-line-9)">https://chat.mistral.ai/code/extensions?focus=key</text><text class="terminal-r1" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r5" x="48.8" y="312.8" textLength="24.4" clip-path="url(#terminal-line-12)">┌─</text><text class="terminal-r3" x="73.2" y="312.8" textLength="183" clip-path="url(#terminal-line-12)">&#160;Paste&#160;API&#160;key&#160;</text><text class="terminal-r5" x="256.2" y="312.8" textLength="695.4" clip-path="url(#terminal-line-12)">────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Before After
Before After

View file

@ -189,7 +189,7 @@
</text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="24.4" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">&#160;Feature&#160;2</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="24.4" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">&#160;Feature&#160;3</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r6" x="24.4" y="654.4" textLength="183" clip-path="url(#terminal-line-26)">Switch&#160;to&#160;your&#160;</text><text class="terminal-r7" x="207.4" y="654.4" textLength="231.8" clip-path="url(#terminal-line-26)">Le&#160;Chat&#160;Pro&#160;API&#160;key</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r6" x="24.4" y="654.4" textLength="183" clip-path="url(#terminal-line-26)">Switch&#160;to&#160;your&#160;</text><text class="terminal-r7" x="207.4" y="654.4" textLength="195.2" clip-path="url(#terminal-line-26)">Vibe&#160;Pro&#160;API&#160;key</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -189,7 +189,7 @@
</text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="24.4" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">&#160;Feature&#160;2</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="24.4" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">&#160;Feature&#160;3</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r6" x="24.4" y="654.4" textLength="292.8" clip-path="url(#terminal-line-26)">Unlock&#160;more&#160;with&#160;Vibe&#160;-&#160;</text><text class="terminal-r7" x="317.2" y="654.4" textLength="268.4" clip-path="url(#terminal-line-26)">Upgrade&#160;to&#160;Le&#160;Chat&#160;Pro</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r6" x="24.4" y="654.4" textLength="292.8" clip-path="url(#terminal-line-26)">Unlock&#160;more&#160;with&#160;Vibe&#160;-&#160;</text><text class="terminal-r7" x="317.2" y="654.4" textLength="231.8" clip-path="url(#terminal-line-26)">Upgrade&#160;to&#160;Vibe&#160;Pro</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -1,14 +1,23 @@
from __future__ import annotations
from typing import Any
import pytest
from textual.app import App
from textual.pilot import Pilot
from tests.snapshots.snap_compare import SnapCompare
from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
from vibe.core.config import ProviderConfig
from vibe.core.types import Backend
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
class StaticPetitChat(PetitChat):
def __init__(self, **kwargs: Any) -> None:
super().__init__(animate=False, **kwargs)
class ApiKeyScreenSnapshotApp(App[str | None]):
CSS_PATH = "../../vibe/setup/onboarding/onboarding.tcss"
@ -27,8 +36,12 @@ class ApiKeyScreenSnapshotApp(App[str | None]):
def test_snapshot_onboarding_api_key_with_valid_input(
snap_compare: SnapCompare,
snap_compare: SnapCompare, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
"vibe.setup.onboarding.screens.api_key.PetitChat", StaticPetitChat
)
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
await pilot.press(*"sk-test-api-key")

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import AsyncGenerator, Callable, Iterable, Sequence
from collections.abc import AsyncGenerator, Iterable
from typing import cast
from tests.mock.utils import mock_llm_chunk
@ -21,7 +21,6 @@ class FakeBackend:
| Iterable[Iterable[LLMChunk]]
| None = None,
*,
token_counter: Callable[[Sequence[LLMMessage]], int] | None = None,
exception_to_raise: Exception | None = None,
) -> None:
"""Fake backend that will output the given chunks in the order they are given.
@ -36,8 +35,6 @@ class FakeBackend:
self._requests_messages: list[list[LLMMessage]] = []
self._requests_extra_headers: list[dict[str, str] | None] = []
self._requests_metadata: list[dict[str, str] | None] = []
self._count_tokens_calls: list[list[LLMMessage]] = []
self._token_counter = token_counter or self._default_token_counter
self._exception_to_raise = exception_to_raise
self._streams: list[list[LLMChunk]]
@ -70,10 +67,6 @@ class FakeBackend:
def requests_metadata(self) -> list[dict[str, str] | None]:
return self._requests_metadata
@staticmethod
def _default_token_counter(messages: Sequence[LLMMessage]) -> int:
return 1
async def __aenter__(self):
return self
@ -133,20 +126,3 @@ class FakeBackend:
stream = [mock_llm_chunk(content="")]
for chunk in stream:
yield chunk
async def count_tokens(
self,
*,
model,
messages,
temperature=0.0,
tools,
tool_choice=None,
extra_headers,
metadata=None,
) -> int:
self._requests_messages.append(list(messages))
self._requests_extra_headers.append(extra_headers)
self._requests_metadata.append(metadata)
self._count_tokens_calls.append(list(messages))
return self._token_counter(messages)

View file

@ -60,13 +60,11 @@ async def test_auto_compact_emits_correct_events(telemetry_events: list[dict]) -
final: AssistantEvent = events[3]
assert start.current_context_tokens == 2
assert start.threshold == 1
assert end.old_context_tokens == 2
assert end.new_context_tokens >= 1
assert isinstance(end, CompactEndEvent)
assert final.content == "<final>"
properties = _get_auto_compact_properties(telemetry_events)
assert properties["nb_context_tokens_before"] == 2
assert properties["nb_context_tokens_after"] == end.new_context_tokens
assert properties["auto_compact_threshold"] == 1
assert properties["status"] == "success"
assert properties["session_id"] == old_session_id
@ -119,7 +117,6 @@ async def test_auto_compact_emits_terminal_telemetry(
properties = _get_auto_compact_properties(telemetry_events)
assert properties["nb_context_tokens_before"] == 2
assert properties["nb_context_tokens_after"] == 2
assert properties["auto_compact_threshold"] == 1
assert properties["status"] == expected_status
assert properties["session_id"] == old_session_id

View file

@ -335,8 +335,8 @@ async def test_mistral_metadata_header_call_type_per_turn() -> None:
@pytest.mark.asyncio
async def test_auto_compact_emits_summary_recount_and_next_turn_metadata() -> None:
"""Compact emits summary, token-count, then user-turn backend metadata in order."""
async def test_auto_compact_emits_summary_and_next_turn_metadata() -> None:
"""Compact emits summary then user-turn backend metadata in order."""
backend = FakeBackend([
[mock_llm_chunk(content="<summary>")],
[mock_llm_chunk(content="<final>")],
@ -358,32 +358,24 @@ async def test_auto_compact_emits_summary_recount_and_next_turn_metadata() -> No
[_ async for _ in agent.act("Hello")]
assert len(backend.requests_metadata) == 3
assert len(backend.requests_extra_headers) == 3
assert len(backend.requests_metadata) == 2
assert len(backend.requests_extra_headers) == 2
compact_metadata = backend.requests_metadata[0]
recount_metadata = backend.requests_metadata[1]
user_turn_metadata = backend.requests_metadata[2]
user_turn_metadata = backend.requests_metadata[1]
assert compact_metadata is not None
assert recount_metadata is not None
assert user_turn_metadata is not None
assert compact_metadata["call_type"] == "secondary_call"
assert compact_metadata["session_id"] == original_session_id
assert "parent_session_id" not in compact_metadata
assert recount_metadata["call_type"] == "secondary_call"
assert recount_metadata["session_id"] == agent.session_id
assert recount_metadata["parent_session_id"] == original_session_id
assert user_turn_metadata["call_type"] == "main_call"
assert user_turn_metadata["session_id"] == agent.session_id
assert user_turn_metadata["parent_session_id"] == original_session_id
compact_headers = backend.requests_extra_headers[0]
recount_headers = backend.requests_extra_headers[1]
user_turn_headers = backend.requests_extra_headers[2]
user_turn_headers = backend.requests_extra_headers[1]
assert compact_headers is not None
assert recount_headers is not None
assert user_turn_headers is not None
assert compact_headers["x-affinity"] == original_session_id
assert recount_headers["x-affinity"] == agent.session_id
assert user_turn_headers["x-affinity"] == agent.session_id

View file

@ -526,13 +526,10 @@ class TestAutoCompactIntegration:
assert isinstance(events[3], AssistantEvent)
start: CompactStartEvent = events[1]
end: CompactEndEvent = events[2]
final: AssistantEvent = events[3]
assert start.current_context_tokens == 2
assert start.threshold == 1
assert end.old_context_tokens == 2
assert end.new_context_tokens >= 1
assert final.content == "<final>"
roles = [r for r, _ in observed]

View file

@ -481,6 +481,54 @@ class TestACloseCancelsExperimentsTask:
assert not task.cancelled()
class TestCycleAgentDuringInit:
@pytest.mark.asyncio
async def test_shift_tab_during_experiments_init_does_not_crash(self) -> None:
"""Regression: shift+tab during init crashed with
RuntimeError("await wasn't used with future").
_cycle_agent ran switch_agent via asyncio.run() in a thread worker,
creating a second event loop. wait_until_ready then tried to await
_experiments_task (owned by the main Textual loop) from that new loop.
The fix uses asyncio.run_coroutine_threadsafe() to schedule on the
main loop instead. This test presses shift+tab while experiments
are still initializing and asserts the worker completes without error.
"""
from tests.conftest import build_test_vibe_app
gate = asyncio.Event()
async def slow_init() -> None:
await gate.wait()
agent_loop = build_test_agent_loop()
app = build_test_vibe_app(agent_loop=agent_loop)
async with app.run_test() as pilot:
with patch.object(
agent_loop, "initialize_experiments", side_effect=slow_init
):
agent_loop._experiments_task = None # reset so start_ re-fires
agent_loop.start_initialize_experiments()
assert agent_loop._experiments_task is not None
assert not agent_loop._experiments_task.done()
# Press shift+tab while experiments are still running.
await pilot.press("shift+tab")
await pilot.pause(0.05)
# Unblock experiments so switch_agent can complete.
gate.set()
# wait_for_complete raises WorkerFailed if the thread worker
# crashed — which is exactly what happened before the fix.
await pilot.app.workers.wait_for_complete()
assert agent_loop.agent_profile.name == "plan"
class TestActGatesOnExperiments:
@pytest.mark.asyncio
async def test_act_awaits_experiments_before_llm_call(self) -> None:

View file

@ -1,9 +1,11 @@
from __future__ import annotations
import contextlib
import logging
import os
import threading
import time
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
@ -20,9 +22,12 @@ from vibe.core.tools.mcp import (
_mcp_stderr_capture,
_parse_call_result,
_stderr_logger_thread,
call_tool_http,
call_tool_stdio,
create_mcp_http_proxy_tool_class,
create_mcp_stdio_proxy_tool_class,
create_vibe_mcp_http_client,
list_tools_http,
list_tools_stdio,
)
@ -133,6 +138,130 @@ class TestParseCallResult:
assert result.text == "line1\nline2"
class TestMCPHttpClient:
def test_create_vibe_mcp_http_client_uses_vibe_ssl_context(self):
headers = {"Authorization": "Bearer token"}
ssl_context = object()
fake_client = object()
with (
patch(
"vibe.core.tools.mcp.tools.build_ssl_context", return_value=ssl_context
),
patch(
"vibe.core.tools.mcp.tools.httpx.AsyncClient", return_value=fake_client
) as async_client,
):
client = create_vibe_mcp_http_client(headers)
assert client is fake_client
kwargs = async_client.call_args.kwargs
assert kwargs["follow_redirects"] is True
assert kwargs["headers"] == headers
assert kwargs["verify"] is ssl_context
assert kwargs["timeout"].connect == 30.0
assert kwargs["timeout"].read == 300.0
@pytest.mark.asyncio
async def test_list_tools_http_uses_vibe_mcp_http_client(self):
fake_client = _FakeHttpClient()
captured: dict[str, Any] = {}
@contextlib.asynccontextmanager
async def fake_stream(url: str, *, http_client: Any):
captured["url"] = url
captured["http_client"] = http_client
yield object(), object(), lambda: None
with (
patch(
"vibe.core.tools.mcp.tools.create_vibe_mcp_http_client",
return_value=fake_client,
) as create_client,
patch("vibe.core.tools.mcp.tools.streamable_http_client", fake_stream),
patch("vibe.core.tools.mcp.tools.ClientSession", _FakeMCPClientSession),
):
tools = await list_tools_http(
"https://mcp.example.com",
headers={"Authorization": "Bearer token"},
startup_timeout_sec=42.0,
)
create_client.assert_called_once_with({"Authorization": "Bearer token"})
assert fake_client.entered is True
assert fake_client.closed is True
assert captured["url"] == "https://mcp.example.com"
assert captured["http_client"] is fake_client
assert [tool.name for tool in tools] == ["remote_tool"]
@pytest.mark.asyncio
async def test_call_tool_http_uses_vibe_mcp_http_client(self):
fake_client = _FakeHttpClient()
captured: dict[str, Any] = {}
@contextlib.asynccontextmanager
async def fake_stream(url: str, *, http_client: Any):
captured["url"] = url
captured["http_client"] = http_client
yield object(), object(), lambda: None
with (
patch(
"vibe.core.tools.mcp.tools.create_vibe_mcp_http_client",
return_value=fake_client,
) as create_client,
patch("vibe.core.tools.mcp.tools.streamable_http_client", fake_stream),
patch("vibe.core.tools.mcp.tools.ClientSession", _FakeMCPClientSession),
):
result = await call_tool_http(
"https://mcp.example.com",
"remote_tool",
{"query": "hello"},
headers={"Authorization": "Bearer token"},
startup_timeout_sec=42.0,
tool_timeout_sec=12.0,
)
create_client.assert_called_once_with({"Authorization": "Bearer token"})
assert fake_client.entered is True
assert fake_client.closed is True
assert captured["url"] == "https://mcp.example.com"
assert captured["http_client"] is fake_client
assert result.structured == {"ok": True}
class _FakeHttpClient:
def __init__(self) -> None:
self.entered = False
self.closed = False
async def __aenter__(self) -> _FakeHttpClient:
self.entered = True
return self
async def __aexit__(self, *_: Any) -> None:
self.closed = True
class _FakeMCPClientSession:
def __init__(self, *_: Any, **__: Any) -> None:
pass
async def __aenter__(self) -> _FakeMCPClientSession:
return self
async def __aexit__(self, *_: Any) -> None:
pass
async def initialize(self) -> None:
pass
async def list_tools(self) -> SimpleNamespace:
return SimpleNamespace(tools=[{"name": "remote_tool"}])
async def call_tool(self, *_: Any, **__: Any) -> SimpleNamespace:
return SimpleNamespace(structuredContent={"ok": True}, content=None)
class TestMCPStderrCapture:
"""Tests for _mcp_stderr_capture and _stderr_logger_thread."""