v2.13.0 (#733)
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:
parent
198277af3f
commit
ad0d5c9520
61 changed files with 969 additions and 461 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
140
tests/backend/test_ssl_integration.py
Normal file
140
tests/backend/test_ssl_integration.py
Normal 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)
|
||||
)
|
||||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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) → "
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"> ⢠⢢    ⡔⢄⠔⡄</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)"> ⢸⢸⣀⡔⢉⠱⣃⡐⣔⡣</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)"> ⠈⠒⠒⠣⠤⠵⠤⠬⠮⠆</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)">  ⡠⣒⠄  ⡔⢄⠔⡄</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)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</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)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</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 your Mistral API 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 AI Studio to generate or copy your Vibe 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 Mistral Vibe to generate or copy your Vibe 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)"> Paste API key </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 |
|
|
@ -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)">• Feature 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)">• Feature 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 to your </text><text class="terminal-r7" x="207.4" y="654.4" textLength="231.8" clip-path="url(#terminal-line-26)">Le Chat Pro API 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 to your </text><text class="terminal-r7" x="207.4" y="654.4" textLength="195.2" clip-path="url(#terminal-line-26)">Vibe Pro API 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 |
|
|
@ -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)">• Feature 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)">• Feature 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 more with Vibe - </text><text class="terminal-r7" x="317.2" y="654.4" textLength="268.4" clip-path="url(#terminal-line-26)">Upgrade to Le Chat 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 more with Vibe - </text><text class="terminal-r7" x="317.2" y="654.4" textLength="231.8" clip-path="url(#terminal-line-26)">Upgrade to Vibe 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 |
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue