From ad0d5c9520785065ff39e3e69958c85863e5fd43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Drouin?= Date: Fri, 29 May 2026 16:17:54 +0200 Subject: [PATCH] v2.13.0 (#733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Maxime Dolores Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Peter Evers Co-authored-by: Pierre Rossinès Co-authored-by: Quentin 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 --- .github/ISSUE_TEMPLATE/bug-report.yml | 1 - .github/workflows/issue-labeler.yml | 4 +- CHANGELOG.md | 17 +++ README.md | 10 ++ distribution/zed/extension.toml | 12 +- pyproject.toml | 56 ++++++- tests/acp/test_auth_status.py | 21 +-- tests/acp/test_initialize.py | 4 +- tests/backend/test_anthropic_adapter.py | 93 ++---------- tests/backend/test_ssl_integration.py | 140 ++++++++++++++++++ .../backend/test_vertex_anthropic_adapter.py | 7 +- .../cli/plan_offer/test_decide_plan_offer.py | 42 ++++++ tests/cli/test_compact_message.py | 4 +- tests/core/test_build_ssl_context.py | 58 +++++++- tests/core/test_config_resolution.py | 72 +++++++++ tests/core/test_telemetry_send.py | 7 +- tests/core/test_utils.py | 14 +- tests/e2e/mock_server.py | 15 +- tests/onboarding/test_ui_onboarding.py | 27 +++- tests/setup/auth/test_auth_state.py | 2 +- ...ot_onboarding_api_key_with_valid_input.svg | 10 +- .../test_snapshot_shows_switch_message.svg | 2 +- .../test_snapshot_shows_upgrade_message.svg | 2 +- .../test_ui_snapshot_onboarding_api_key.py | 15 +- tests/stubs/fake_backend.py | 26 +--- tests/test_agent_auto_compact.py | 5 +- tests/test_agent_backend.py | 20 +-- tests/test_agent_stats.py | 3 - tests/test_deferred_init.py | 48 ++++++ tests/tools/test_mcp.py | 129 ++++++++++++++++ uv.lock | 116 ++++++++++++++- vibe/__init__.py | 2 +- vibe/acp/acp_agent_loop.py | 19 ++- vibe/acp/utils.py | 6 +- vibe/cli/plan_offer/decide_plan_offer.py | 12 +- vibe/cli/textual_ui/app.py | 23 ++- vibe/cli/textual_ui/app.tcss | 4 - vibe/cli/textual_ui/handlers/event_handler.py | 5 +- vibe/cli/textual_ui/widgets/compact.py | 20 +-- vibe/core/agent_loop.py | 22 +-- vibe/core/compaction.py | 6 +- vibe/core/config/__init__.py | 2 + vibe/core/config/_settings.py | 11 +- vibe/core/llm/backend/anthropic.py | 65 ++------ vibe/core/llm/backend/generic.py | 29 ---- vibe/core/llm/backend/mistral.py | 26 ---- vibe/core/llm/backend/vertex.py | 2 - vibe/core/llm/types.py | 34 +---- vibe/core/skills/builtins/vibe.py | 8 + vibe/core/telemetry/send.py | 2 - vibe/core/tools/mcp/__init__.py | 2 + vibe/core/tools/mcp/tools.py | 61 ++++++-- vibe/core/types.py | 2 - vibe/core/utils/__init__.py | 6 +- vibe/core/utils/display.py | 18 +-- vibe/core/utils/http.py | 16 +- vibe/setup/auth/auth_state.py | 1 + vibe/setup/onboarding/__init__.py | 7 +- vibe/setup/onboarding/context.py | 16 +- vibe/setup/onboarding/screens/api_key.py | 19 ++- vibe/whats_new.md | 2 +- 61 files changed, 969 insertions(+), 461 deletions(-) create mode 100644 tests/backend/test_ssl_integration.py diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 5bc81a2..ebace8f 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -17,7 +17,6 @@ body: options: - CLI - ACP - - Both - VS Code extension - Other validations: diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index 0bc4b0e..b02b524 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -47,8 +47,8 @@ jobs: labels.push('CLI'); } else if (component === 'ACP') { labels.push('ACP'); - } else if (component === 'Both') { - labels.push('CLI', 'ACP'); + } else if (component === 'VS Code extension') { + labels.push('VS Code extension'); } if (labels.length > 0) { diff --git a/CHANGELOG.md b/CHANGELOG.md index 611d2ea..3a1f564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.13.0] - 2026-05-29 + +### Added + +- `enable_system_trust_store` config flag to switch the shared SSL context to the OS trust store for corporate TLS / private CA setups + +### Changed + +- MCP HTTP transport now uses Vibe's shared SSL context so it honors `SSL_CERT_FILE` / `SSL_CERT_DIR` and the system trust store opt-in +- API key onboarding and plan-upgrade CTAs now link to the new Mistral Vibe Code extensions page +- Compaction summaries are now injected into the conversation instead of replacing it + +### Fixed + +- Crash during initialization +- VS Code extension promo banner now sits at the top of the conversation instead of being pinned above the input + ## [2.12.1] - 2026-05-27 ### Fixed diff --git a/README.md b/README.md index cda97da..eb4be39 100644 --- a/README.md +++ b/README.md @@ -425,6 +425,16 @@ Vibe supports multiple ways to configure your API keys: **Note**: The `.env` file is specifically for API keys and other provider credentials. General Vibe configuration should be done in `config.toml`. +### TLS and Corporate Certificate Authorities + +By default, Vibe uses the bundled `certifi` certificate roots for outbound HTTPS requests. If your organization installs private certificate authorities in the operating system trust store, you can opt in to the system trust store in `config.toml`: + +```toml +enable_system_trust_store = true +``` + +`SSL_CERT_FILE` and `SSL_CERT_DIR` are still supported and are loaded as additional trust anchors. + ### Custom System Prompts You can create `AGENTS.md` files to add custom instructions. You can also replace the entire system prompt. diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index 117ebe5..c1437a1 100644 --- a/distribution/zed/extension.toml +++ b/distribution/zed/extension.toml @@ -1,7 +1,7 @@ id = "mistral-vibe" name = "Mistral Vibe" description = "Mistral's open-source coding assistant" -version = "2.12.1" +version = "2.13.0" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,21 +11,21 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.12.1/vibe-acp-darwin-aarch64-2.12.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-darwin-aarch64-2.13.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.12.1/vibe-acp-darwin-x86_64-2.12.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-darwin-x86_64-2.13.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.12.1/vibe-acp-linux-aarch64-2.12.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-linux-aarch64-2.13.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.12.1/vibe-acp-linux-x86_64-2.12.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-linux-x86_64-2.13.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.12.1/vibe-acp-windows-x86_64-2.12.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-windows-x86_64-2.13.0.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index d1877ee..eb8b4ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.12.1" +version = "2.13.0" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" @@ -64,7 +64,7 @@ dependencies = [ "linkify-it-py==2.1.0", "markdown-it-py==4.0.0", "markdownify==1.2.2", - "mcp==1.27.0", + "mcp==1.27.1", "mdit-py-plugins==0.5.0", "mdurl==0.1.2", "mistralai==2.4.4", @@ -111,6 +111,7 @@ dependencies = [ "tomli-w==1.2.0", "tree-sitter==0.25.2", "tree-sitter-bash==0.25.1", + "truststore==0.10.4", "typing-extensions==4.15.0", "typing-inspection==0.4.2", "uc-micro-py==2.0.0", @@ -173,14 +174,65 @@ dev = [ build = [ "altgraph==0.17.5", + "anyio==4.13.0", + "certifi==2026.4.22", + "cffi==2.0.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'", + "cfgv==3.5.0", + "charset-normalizer==3.4.7", + "colorama==0.4.6 ; sys_platform == 'win32'", + "cryptography==47.0.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", + "distlib==0.4.0", + "docutils==0.22.4", + "execnet==2.1.2", + "filelock==3.29.0", + "h11==0.16.0", + "httpcore==1.0.9", + "httpx==0.28.1", + "id==1.6.1", + "identify==2.6.19", + "idna==3.13", + "iniconfig==2.3.0", + "jaraco-classes==3.4.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x'", + "jaraco-context==6.1.2 ; platform_machine != 'ppc64le' and platform_machine != 's390x'", + "jaraco-functools==4.4.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x'", + "jeepney==0.9.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", + "jinja2==3.1.6", + "keyring==25.7.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x'", + "linkify-it-py==2.1.0", "macholib==1.16.4 ; sys_platform == 'darwin'", + "markdown-it-py==4.0.0", + "markupsafe==3.0.3", + "mdit-py-plugins==0.5.0", + "mdurl==0.1.2", + "more-itertools==11.0.2 ; platform_machine != 'ppc64le' and platform_machine != 's390x'", + "nh3==0.3.5", + "nodeenv==1.10.0", "packaging==26.2", "pefile==2024.8.26 ; sys_platform == 'win32'", + "platformdirs==4.9.6", + "pluggy==1.6.0", + "pycparser==3.0 ; implementation_name != 'PyPy' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'", + "pygments==2.20.0", "pyinstaller==6.20.0", "pyinstaller-hooks-contrib==2026.4", + "pyinstrument==5.1.2", + "python-discovery==1.2.2", "pywin32-ctypes==0.2.3 ; sys_platform == 'win32'", + "pyyaml==6.0.3", + "readme-renderer==44.0", + "requests==2.33.1", + "requests-toolbelt==1.0.0", + "rfc3986==2.0.0", + "rich==15.0.0", + "secretstorage==3.5.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", "setuptools==82.0.1", + "syrupy==4.8.0", + "textual==8.2.7", "truststore==0.10.4", + "typing-extensions==4.15.0", + "uc-micro-py==2.0.0", + "urllib3==2.6.3", + "virtualenv==21.2.4", ] [tool.pyright] diff --git a/tests/acp/test_auth_status.py b/tests/acp/test_auth_status.py index b322a20..b0703c1 100644 --- a/tests/acp/test_auth_status.py +++ b/tests/acp/test_auth_status.py @@ -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( diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index 6fffe5f..17ede35 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -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 diff --git a/tests/backend/test_anthropic_adapter.py b/tests/backend/test_anthropic_adapter.py index c316d17..68e4906 100644 --- a/tests/backend/test_anthropic_adapter.py +++ b/tests/backend/test_anthropic_adapter.py @@ -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 diff --git a/tests/backend/test_ssl_integration.py b/tests/backend/test_ssl_integration.py new file mode 100644 index 0000000..045c7b1 --- /dev/null +++ b/tests/backend/test_ssl_integration.py @@ -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) + ) diff --git a/tests/backend/test_vertex_anthropic_adapter.py b/tests/backend/test_vertex_anthropic_adapter.py index 77b905c..c229e43 100644 --- a/tests/backend/test_vertex_anthropic_adapter.py +++ b/tests/backend/test_vertex_anthropic_adapter.py @@ -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")] diff --git a/tests/cli/plan_offer/test_decide_plan_offer.py b/tests/cli/plan_offer/test_decide_plan_offer.py index 95a1d40..66bdb54 100644 --- a/tests/cli/plan_offer/test_decide_plan_offer.py +++ b/tests/cli/plan_offer/test_decide_plan_offer.py @@ -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"), [ diff --git a/tests/cli/test_compact_message.py b/tests/cli/test_compact_message.py index 0183f0a..1cb802b 100644 --- a/tests/cli/test_compact_message.py +++ b/tests/cli/test_compact_message.py @@ -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) → " diff --git a/tests/core/test_build_ssl_context.py b/tests/core/test_build_ssl_context.py index 95b04cc..0b79a4b 100644 --- a/tests/core/test_build_ssl_context.py +++ b/tests/core/test_build_ssl_context.py @@ -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() diff --git a/tests/core/test_config_resolution.py b/tests/core/test_config_resolution.py index 6f1279a..50b534d 100644 --- a/tests/core/test_config_resolution.py +++ b/tests/core/test_config_resolution.py @@ -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 diff --git a/tests/core/test_telemetry_send.py b/tests/core/test_telemetry_send.py index 580f0bd..4141d5d 100644 --- a/tests/core/test_telemetry_send.py +++ b/tests/core/test_telemetry_send.py @@ -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", diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index 7fcc01a..450006c 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -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: diff --git a/tests/e2e/mock_server.py b/tests/e2e/mock_server.py index fea88e1..c69a4c8 100644 --- a/tests/e2e/mock_server.py +++ b/tests/e2e/mock_server.py @@ -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() diff --git a/tests/onboarding/test_ui_onboarding.py b/tests/onboarding/test_ui_onboarding.py index 8333368..c32f5ae 100644 --- a/tests/onboarding/test_ui_onboarding.py +++ b/tests/onboarding/test_ui_onboarding.py @@ -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" diff --git a/tests/setup/auth/test_auth_state.py b/tests/setup/auth/test_auth_state.py index 5d28797..2b6b1b6 100644 --- a/tests/setup/auth/test_auth_state.py +++ b/tests/setup/auth/test_auth_state.py @@ -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, ) diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_onboarding_api_key/test_snapshot_onboarding_api_key_with_valid_input.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_onboarding_api_key/test_snapshot_onboarding_api_key_with_valid_input.svg index 8dd73b0..17e78cb 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_onboarding_api_key/test_snapshot_onboarding_api_key_with_valid_input.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_onboarding_api_key/test_snapshot_onboarding_api_key_with_valid_input.svg @@ -129,13 +129,13 @@ - ⢠⢢    ⡔⢄⠔⡄ - ⢸⢸⣀⡔⢉⠱⣃⡐⣔⡣ - ⠈⠒⠒⠣⠤⠵⠤⠬⠮⠆ +  ⡠⣒⠄  ⡔⢄⠔⡄ + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣ +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆ Get your Mistral API key -Visit AI Studio to generate or copy your Vibe key +Visit Mistral Vibe to generate or copy your Vibe key -https://console.mistral.ai/codestral/cli +https://chat.mistral.ai/code/extensions?focus=key ┌─ Paste API key ────────────────────────────────────────────────────────┐ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg index fdd97eb..4a6b70c 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg @@ -189,7 +189,7 @@ • Feature 2 • Feature 3 -Switch to your Le Chat Pro API key +Switch to your Vibe Pro API key diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg index c956d8a..73490ac 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg @@ -189,7 +189,7 @@ • Feature 2 • Feature 3 -Unlock more with Vibe - Upgrade to Le Chat Pro +Unlock more with Vibe - Upgrade to Vibe Pro diff --git a/tests/snapshots/test_ui_snapshot_onboarding_api_key.py b/tests/snapshots/test_ui_snapshot_onboarding_api_key.py index 47ea91f..4cbab47 100644 --- a/tests/snapshots/test_ui_snapshot_onboarding_api_key.py +++ b/tests/snapshots/test_ui_snapshot_onboarding_api_key.py @@ -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") diff --git a/tests/stubs/fake_backend.py b/tests/stubs/fake_backend.py index 4fd042d..e7e46c5 100644 --- a/tests/stubs/fake_backend.py +++ b/tests/stubs/fake_backend.py @@ -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) diff --git a/tests/test_agent_auto_compact.py b/tests/test_agent_auto_compact.py index 94d4ec0..9dd4458 100644 --- a/tests/test_agent_auto_compact.py +++ b/tests/test_agent_auto_compact.py @@ -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 == "" 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 diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py index faf0bc4..a1ac706 100644 --- a/tests/test_agent_backend.py +++ b/tests/test_agent_backend.py @@ -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="")], [mock_llm_chunk(content="")], @@ -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 diff --git a/tests/test_agent_stats.py b/tests/test_agent_stats.py index c0c5afc..81dfc54 100644 --- a/tests/test_agent_stats.py +++ b/tests/test_agent_stats.py @@ -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 == "" roles = [r for r, _ in observed] diff --git a/tests/test_deferred_init.py b/tests/test_deferred_init.py index 46c835c..cc05058 100644 --- a/tests/test_deferred_init.py +++ b/tests/test_deferred_init.py @@ -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: diff --git a/tests/tools/test_mcp.py b/tests/tools/test_mcp.py index 96a27a1..b6f33c2 100644 --- a/tests/tools/test_mcp.py +++ b/tests/tools/test_mcp.py @@ -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.""" diff --git a/uv.lock b/uv.lock index fae5117..389526a 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.12" [options] -exclude-newer = "2026-05-20T19:55:04.890389Z" +exclude-newer = "2026-05-22T14:05:58.077106Z" exclude-newer-span = "P7D" [options.exclude-newer-package] @@ -775,7 +775,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.27.0" +version = "1.27.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -793,9 +793,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" }, ] [[package]] @@ -821,7 +821,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.12.1" +version = "2.13.0" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, @@ -908,6 +908,7 @@ dependencies = [ { name = "tomli-w" }, { name = "tree-sitter" }, { name = "tree-sitter-bash" }, + { name = "truststore" }, { name = "typing-extensions" }, { name = "typing-inspection" }, { name = "uc-micro-py" }, @@ -922,14 +923,65 @@ dependencies = [ [package.dev-dependencies] build = [ { name = "altgraph" }, + { name = "anyio" }, + { name = "certifi" }, + { name = "cffi", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'" }, + { name = "cfgv" }, + { name = "charset-normalizer" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "cryptography", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'" }, + { name = "distlib" }, + { name = "docutils" }, + { name = "execnet" }, + { name = "filelock" }, + { name = "h11" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "id" }, + { name = "identify" }, + { name = "idna" }, + { name = "iniconfig" }, + { name = "jaraco-classes", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "jaraco-context", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "jaraco-functools", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "jeepney", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'" }, + { name = "jinja2" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "linkify-it-py" }, { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "markdown-it-py" }, + { name = "markupsafe" }, + { name = "mdit-py-plugins" }, + { name = "mdurl" }, + { name = "more-itertools", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "nh3" }, + { name = "nodeenv" }, { name = "packaging" }, { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pycparser", marker = "implementation_name != 'PyPy' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'" }, + { name = "pygments" }, { name = "pyinstaller" }, { name = "pyinstaller-hooks-contrib" }, + { name = "pyinstrument" }, + { name = "python-discovery" }, { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "secretstorage", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'" }, { name = "setuptools" }, + { name = "syrupy" }, + { name = "textual" }, { name = "truststore" }, + { name = "typing-extensions" }, + { name = "uc-micro-py" }, + { name = "urllib3" }, + { name = "virtualenv" }, ] dev = [ { name = "debugpy" }, @@ -988,7 +1040,7 @@ requires-dist = [ { name = "linkify-it-py", specifier = "==2.1.0" }, { name = "markdown-it-py", specifier = "==4.0.0" }, { name = "markdownify", specifier = "==1.2.2" }, - { name = "mcp", specifier = "==1.27.0" }, + { name = "mcp", specifier = "==1.27.1" }, { name = "mdit-py-plugins", specifier = "==0.5.0" }, { name = "mdurl", specifier = "==0.1.2" }, { name = "mistralai", specifier = "==2.4.4" }, @@ -1035,6 +1087,7 @@ requires-dist = [ { name = "tomli-w", specifier = "==1.2.0" }, { name = "tree-sitter", specifier = "==0.25.2" }, { name = "tree-sitter-bash", specifier = "==0.25.1" }, + { name = "truststore", specifier = "==0.10.4" }, { name = "typing-extensions", specifier = "==4.15.0" }, { name = "typing-inspection", specifier = "==0.4.2" }, { name = "uc-micro-py", specifier = "==2.0.0" }, @@ -1049,14 +1102,65 @@ requires-dist = [ [package.metadata.requires-dev] build = [ { name = "altgraph", specifier = "==0.17.5" }, + { name = "anyio", specifier = "==4.13.0" }, + { name = "certifi", specifier = "==2026.4.22" }, + { name = "cffi", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'", specifier = "==2.0.0" }, + { name = "cfgv", specifier = "==3.5.0" }, + { name = "charset-normalizer", specifier = "==3.4.7" }, + { name = "colorama", marker = "sys_platform == 'win32'", specifier = "==0.4.6" }, + { name = "cryptography", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", specifier = "==47.0.0" }, + { name = "distlib", specifier = "==0.4.0" }, + { name = "docutils", specifier = "==0.22.4" }, + { name = "execnet", specifier = "==2.1.2" }, + { name = "filelock", specifier = "==3.29.0" }, + { name = "h11", specifier = "==0.16.0" }, + { name = "httpcore", specifier = "==1.0.9" }, + { name = "httpx", specifier = "==0.28.1" }, + { name = "id", specifier = "==1.6.1" }, + { name = "identify", specifier = "==2.6.19" }, + { name = "idna", specifier = "==3.13" }, + { name = "iniconfig", specifier = "==2.3.0" }, + { name = "jaraco-classes", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==3.4.0" }, + { name = "jaraco-context", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==6.1.2" }, + { name = "jaraco-functools", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==4.4.0" }, + { name = "jeepney", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", specifier = "==0.9.0" }, + { name = "jinja2", specifier = "==3.1.6" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==25.7.0" }, + { name = "linkify-it-py", specifier = "==2.1.0" }, { name = "macholib", marker = "sys_platform == 'darwin'", specifier = "==1.16.4" }, + { name = "markdown-it-py", specifier = "==4.0.0" }, + { name = "markupsafe", specifier = "==3.0.3" }, + { name = "mdit-py-plugins", specifier = "==0.5.0" }, + { name = "mdurl", specifier = "==0.1.2" }, + { name = "more-itertools", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==11.0.2" }, + { name = "nh3", specifier = "==0.3.5" }, + { name = "nodeenv", specifier = "==1.10.0" }, { name = "packaging", specifier = "==26.2" }, { name = "pefile", marker = "sys_platform == 'win32'", specifier = "==2024.8.26" }, + { name = "platformdirs", specifier = "==4.9.6" }, + { name = "pluggy", specifier = "==1.6.0" }, + { name = "pycparser", marker = "implementation_name != 'PyPy' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'", specifier = "==3.0" }, + { name = "pygments", specifier = "==2.20.0" }, { name = "pyinstaller", specifier = "==6.20.0" }, { name = "pyinstaller-hooks-contrib", specifier = "==2026.4" }, + { name = "pyinstrument", specifier = "==5.1.2" }, + { name = "python-discovery", specifier = "==1.2.2" }, { name = "pywin32-ctypes", marker = "sys_platform == 'win32'", specifier = "==0.2.3" }, + { name = "pyyaml", specifier = "==6.0.3" }, + { name = "readme-renderer", specifier = "==44.0" }, + { name = "requests", specifier = "==2.33.1" }, + { name = "requests-toolbelt", specifier = "==1.0.0" }, + { name = "rfc3986", specifier = "==2.0.0" }, + { name = "rich", specifier = "==15.0.0" }, + { name = "secretstorage", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", specifier = "==3.5.0" }, { name = "setuptools", specifier = "==82.0.1" }, + { name = "syrupy", specifier = "==4.8.0" }, + { name = "textual", specifier = "==8.2.7" }, { name = "truststore", specifier = "==0.10.4" }, + { name = "typing-extensions", specifier = "==4.15.0" }, + { name = "uc-micro-py", specifier = "==2.0.0" }, + { name = "urllib3", specifier = "==2.6.3" }, + { name = "virtualenv", specifier = "==21.2.4" }, ] dev = [ { name = "debugpy", specifier = ">=1.8.19" }, diff --git a/vibe/__init__.py b/vibe/__init__.py index 90da959..8950d2d 100644 --- a/vibe/__init__.py +++ b/vibe/__init__.py @@ -3,4 +3,4 @@ from __future__ import annotations from pathlib import Path VIBE_ROOT = Path(__file__).parent -__version__ = "2.12.1" +__version__ = "2.13.0" diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py index 8f07fc5..529b00a 100644 --- a/vibe/acp/acp_agent_loop.py +++ b/vibe/acp/acp_agent_loop.py @@ -1516,6 +1516,14 @@ class VibeAcpAgentLoop(AcpAgent): ) return provider, auth_state + def _process_env_value_before_dotenv_load( + self, provider: ProviderConfig + ) -> str | None: + if not provider.api_key_env_var: + return None + + return self._environ_before_dotenv_load.get(provider.api_key_env_var) + def _handle_auth_status(self) -> dict[str, Any]: _, auth_state = self._assess_current_auth_state() return _auth_status_response_from_auth_state(auth_state).model_dump( @@ -1531,6 +1539,14 @@ class VibeAcpAgentLoop(AcpAgent): try: self._remove_api_key(provider) + if ( + auth_state.kind + == AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV + and auth_state.env_key + ): + process_env_value = self._process_env_value_before_dotenv_load(provider) + if process_env_value: + os.environ[auth_state.env_key] = process_env_value except (OSError, ValueError) as exc: raise InternalError(f"Failed to sign out: {exc}") from exc @@ -1650,11 +1666,8 @@ class VibeAcpAgentLoop(AcpAgent): ) await session.agent_loop.compact(extra_instructions=cmd_args.strip()) - new_tokens = session.agent_loop.stats.context_tokens end_event = CompactEndEvent( - old_context_tokens=old_tokens or 0, - new_context_tokens=new_tokens or 0, summary_length=0, old_session_id=old_session_id, new_session_id=session.agent_loop.session_id, diff --git a/vibe/acp/utils.py b/vibe/acp/utils.py index 7f8c3da..36cd74b 100644 --- a/vibe/acp/utils.py +++ b/vibe/acp/utils.py @@ -26,7 +26,7 @@ from vibe.core.config._settings import THINKING_LEVELS, ThinkingLevel from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings from vibe.core.tools.permissions import RequiredPermission from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage -from vibe.core.utils import compact_reduction_display +from vibe.core.utils import compact_complete_display if TYPE_CHECKING: from vibe.core.config import ModelConfig @@ -230,9 +230,7 @@ def create_compact_end_session_update(event: CompactEndEvent) -> ToolCallProgres content=TextContentBlock( type="text", text=( - compact_reduction_display( - event.old_context_tokens, - event.new_context_tokens, + compact_complete_display( old_session_id=event.old_session_id, new_session_id=event.new_session_id, ) diff --git a/vibe/cli/plan_offer/decide_plan_offer.py b/vibe/cli/plan_offer/decide_plan_offer.py index c048d30..fc66fed 100644 --- a/vibe/cli/plan_offer/decide_plan_offer.py +++ b/vibe/cli/plan_offer/decide_plan_offer.py @@ -12,8 +12,8 @@ from vibe.cli.plan_offer.ports.whoami_gateway import ( WhoAmIResponse, ) from vibe.core.config import ( - DEFAULT_CONSOLE_BASE_URL, DEFAULT_MISTRAL_API_ENV_KEY, + DEFAULT_VIBE_BASE_URL, ProviderConfig, ) from vibe.core.types import Backend @@ -97,20 +97,18 @@ def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None: def plan_offer_cta( - payload: PlanInfo | None, console_base_url: str = DEFAULT_CONSOLE_BASE_URL + payload: PlanInfo | None, *, vibe_base_url: str = DEFAULT_VIBE_BASE_URL ) -> str | None: if not payload: return - console_cli_url = f"{console_base_url.rstrip('/')}/codestral/cli" - switch_to_pro_key_url = console_cli_url - upgrade_url = console_cli_url + vibe_api_key_url = f"{vibe_base_url.rstrip('/')}/code/extensions?focus=key" if payload.prompt_switching_to_pro_plan: - return f"### Switch to your [Le Chat Pro API key]({switch_to_pro_key_url})" + return f"### Switch to your [Vibe Pro API key]({vibe_api_key_url})" if ( payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED} or payload.is_free_mistral_code_plan() ): - return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({upgrade_url})" + return f"### Unlock more with Vibe - [Upgrade to Vibe Pro]({vibe_api_key_url})" def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911 diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index bb8fc76..1c826b2 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -2183,32 +2183,26 @@ class VibeApp(App): # noqa: PLR0904 if not self.event_handler: return - old_tokens = self.agent_loop.stats.context_tokens old_session_id = self.agent_loop.session_id compact_msg = CompactMessage() self.event_handler.current_compact = compact_msg await self._mount_and_scroll(compact_msg) self._agent_task = asyncio.create_task( - self._run_compact(compact_msg, old_tokens, old_session_id, cmd_args.strip()) + self._run_compact(compact_msg, old_session_id, cmd_args.strip()) ) async def _run_compact( self, compact_msg: CompactMessage, - old_tokens: int, old_session_id: str, extra_instructions: str = "", ) -> None: self._agent_running = True try: await self.agent_loop.compact(extra_instructions=extra_instructions) - new_tokens = self.agent_loop.stats.context_tokens compact_msg.set_complete( - old_tokens=old_tokens, - new_tokens=new_tokens, - old_session_id=old_session_id, - new_session_id=self.agent_loop.session_id, + old_session_id=old_session_id, new_session_id=self.agent_loop.session_id ) except asyncio.CancelledError: @@ -2887,13 +2881,18 @@ class VibeApp(App): # noqa: PLR0904 if self._chat_input_container: self._chat_input_container.switching_mode = True + loop = asyncio.get_running_loop() + def schedule_switch() -> None: self._switch_agent_generation += 1 my_gen = self._switch_agent_generation def switch_agent_sync() -> None: try: - asyncio.run(self.agent_loop.switch_agent(new_profile.name)) + future = asyncio.run_coroutine_threadsafe( + self.agent_loop.switch_agent(new_profile.name), loop + ) + future.result() self.agent_loop.set_approval_callback(self._approval_callback) self.agent_loop.set_user_input_callback(self._user_input_callback) finally: @@ -3031,7 +3030,7 @@ class VibeApp(App): # noqa: PLR0904 if content is not None: body = content plan_offer = plan_offer_cta( - self._plan_info, console_base_url=self.config.console_base_url + self._plan_info, vibe_base_url=self.config.vibe_base_url ) if plan_offer is not None: body = f"{body}\n\n{plan_offer}" @@ -3059,12 +3058,10 @@ class VibeApp(App): # noqa: PLR0904 if not self._show_vscode_extension_promo: return promo_message = VscodeExtensionPromoMessage() - if self._history_widget_indices: - promo_message.add_class("after-history") messages_area = self._cached_messages_area or self.query_one("#messages") chat = self._cached_chat or self.query_one("#chat", ChatScroll) should_anchor = chat.is_at_bottom - await chat.mount(promo_message, after=messages_area) + await chat.mount(promo_message, before=messages_area) if should_anchor: chat.anchor() self.run_worker(self._record_vscode_extension_promo_shown(), exclusive=False) diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss index 8fb134b..c3f92e1 100644 --- a/vibe/cli/textual_ui/app.tcss +++ b/vibe/cli/textual_ui/app.tcss @@ -1281,10 +1281,6 @@ NarratorStatus { link-color-hover: $mistral_orange; } -.vscode-extension-promo-message.after-history { - margin-top: 1; -} - #sessionpicker-app { width: 100%; height: auto; diff --git a/vibe/cli/textual_ui/handlers/event_handler.py b/vibe/cli/textual_ui/handlers/event_handler.py index 604538c..214cca3 100644 --- a/vibe/cli/textual_ui/handlers/event_handler.py +++ b/vibe/cli/textual_ui/handlers/event_handler.py @@ -229,10 +229,7 @@ class EventHandler: async def _handle_compact_end(self, event: CompactEndEvent) -> None: if self.current_compact: self.current_compact.set_complete( - old_tokens=event.old_context_tokens, - new_tokens=event.new_context_tokens, - old_session_id=event.old_session_id, - new_session_id=event.new_session_id, + old_session_id=event.old_session_id, new_session_id=event.new_session_id ) self.current_compact = None diff --git a/vibe/cli/textual_ui/widgets/compact.py b/vibe/cli/textual_ui/widgets/compact.py index 8d9fc9d..bb40ac3 100644 --- a/vibe/cli/textual_ui/widgets/compact.py +++ b/vibe/cli/textual_ui/widgets/compact.py @@ -3,7 +3,7 @@ from __future__ import annotations from textual.message import Message from vibe.cli.textual_ui.widgets.status_message import StatusMessage -from vibe.core.utils import compact_reduction_display +from vibe.core.utils import compact_complete_display class CompactMessage(StatusMessage): @@ -15,8 +15,6 @@ class CompactMessage(StatusMessage): def __init__(self) -> None: super().__init__() self.add_class("compact-message") - self.old_tokens: int | None = None - self.new_tokens: int | None = None self.old_session_id: str | None = None self.new_session_id: str | None = None self.error_message: str | None = None @@ -28,23 +26,13 @@ class CompactMessage(StatusMessage): if self.error_message: return f"Error: {self.error_message}" - return compact_reduction_display( - self.old_tokens, - self.new_tokens, - old_session_id=self.old_session_id, - new_session_id=self.new_session_id, + return compact_complete_display( + old_session_id=self.old_session_id, new_session_id=self.new_session_id ) def set_complete( - self, - old_tokens: int | None = None, - new_tokens: int | None = None, - *, - old_session_id: str | None = None, - new_session_id: str | None = None, + self, *, old_session_id: str | None = None, new_session_id: str | None = None ) -> None: - self.old_tokens = old_tokens - self.new_tokens = new_tokens self.old_session_id = old_session_id self.new_session_id = new_session_id self.stop_spinning(success=True) diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index 15923a3..505433e 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -810,7 +810,6 @@ class AgentLoop: # noqa: PLR0904 ) compact_status: Literal["success", "failure", "cancelled"] = "success" - new_tokens = self.stats.context_tokens try: summary = await self.compact() except asyncio.CancelledError: @@ -820,10 +819,8 @@ class AgentLoop: # noqa: PLR0904 compact_status = "failure" raise finally: - new_tokens = self.stats.context_tokens self.telemetry_client.send_auto_compact_triggered( nb_context_tokens_before=old_tokens, - nb_context_tokens_after=new_tokens, auto_compact_threshold=threshold, status=compact_status, session_id=old_session_id, @@ -832,8 +829,6 @@ class AgentLoop: # noqa: PLR0904 yield CompactEndEvent( tool_call_id=tool_call_id, - old_context_tokens=old_tokens, - new_context_tokens=new_tokens, summary_length=len(summary), old_session_id=old_session_id, new_session_id=self.session_id, @@ -1724,21 +1719,16 @@ class AgentLoop: # noqa: PLR0904 system_message = self.messages[0] wrapped_summary = f"{summary_prefix}\n{summary_content}" - summary_message = LLMMessage(role=Role.user, content=wrapped_summary) + summary_message = LLMMessage( + role=Role.user, content=wrapped_summary, injected=True + ) self.messages.reset([system_message, *prior_user_messages, summary_message]) - active_model = self.config.get_active_model() await self._reset_session() - actual_context_tokens = await self.backend.count_tokens( - model=active_model, - messages=self.messages, - tools=self.format_handler.get_available_tools(self.tool_manager), - extra_headers=self._get_extra_headers(), - metadata=self._build_backend_metadata().model_dump(exclude_none=True), - ) - - self.stats.context_tokens = actual_context_tokens + # Context size is unknown without an API call; reset to 0. The next + # LLM turn recomputes it accurately from real usage (_update_stats). + self.stats.context_tokens = 0 await self.session_logger.save_interaction( self.messages, self.stats, diff --git a/vibe/core/compaction.py b/vibe/core/compaction.py index 5c9bfa4..4f5d69f 100644 --- a/vibe/core/compaction.py +++ b/vibe/core/compaction.py @@ -34,11 +34,13 @@ def collect_prior_user_messages( content = m.content or "" cost = approx_token_count(content) if cost <= remaining: - selected.append(LLMMessage(role=Role.user, content=content)) + selected.append(LLMMessage(role=Role.user, content=content, injected=True)) remaining -= cost else: truncated = truncate_middle_to_tokens(content, remaining) - selected.append(LLMMessage(role=Role.user, content=truncated)) + selected.append( + LLMMessage(role=Role.user, content=truncated, injected=True) + ) remaining = 0 selected.reverse() diff --git a/vibe/core/config/__init__.py b/vibe/core/config/__init__.py index 1efa34a..6f77f6e 100644 --- a/vibe/core/config/__init__.py +++ b/vibe/core/config/__init__.py @@ -10,6 +10,7 @@ from vibe.core.config._settings import ( DEFAULT_TRANSCRIBE_PROVIDERS, DEFAULT_TTS_MODELS, DEFAULT_TTS_PROVIDERS, + DEFAULT_VIBE_BASE_URL, THINKING_LEVELS, ConnectorConfig, ExperimentsConfig, @@ -76,6 +77,7 @@ __all__ = [ "DEFAULT_TRANSCRIBE_PROVIDERS", "DEFAULT_TTS_MODELS", "DEFAULT_TTS_PROVIDERS", + "DEFAULT_VIBE_BASE_URL", "THINKING_LEVELS", "AppendToList", "ConfigDefinitionError", diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py index f5c3cbe..b834784 100644 --- a/vibe/core/config/_settings.py +++ b/vibe/core/config/_settings.py @@ -32,7 +32,7 @@ from vibe.core.logger import logger from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR from vibe.core.prompts import UtilityPrompt, load_prompt, load_system_prompt from vibe.core.types import Backend -from vibe.core.utils import get_server_url_from_api_base +from vibe.core.utils import configure_ssl_context, get_server_url_from_api_base def _strip_bash_pattern_wildcard(pattern: str) -> str: @@ -165,6 +165,7 @@ DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY" DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL = "https://console.mistral.ai" DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL = "https://console.mistral.ai/api" DEFAULT_CONSOLE_BASE_URL = "https://console.mistral.ai" +DEFAULT_VIBE_BASE_URL = "https://chat.mistral.ai" class ProviderConfig(BaseModel): @@ -520,6 +521,7 @@ class VibeConfig(BaseSettings): enable_update_checks: bool = True enable_auto_update: bool = True enable_notifications: bool = True + enable_system_trust_store: bool = False api_timeout: float = 720.0 auto_compact_threshold: int = 200_000 @@ -537,6 +539,7 @@ class VibeConfig(BaseSettings): otel_endpoint: str = Field(default="", exclude=True) console_base_url: str = Field(default=DEFAULT_CONSOLE_BASE_URL, exclude=True) + vibe_base_url: str = Field(default=DEFAULT_VIBE_BASE_URL, exclude=True) enable_experimental_hooks: bool = Field(default=False, exclude=True) @@ -1064,7 +1067,11 @@ class VibeConfig(BaseSettings): @classmethod def load(cls, **overrides: Any) -> VibeConfig: cls._migrate() - return cls(**(overrides or {})) + config = cls(**(overrides or {})) + configure_ssl_context( + enable_system_trust_store=config.enable_system_trust_store + ) + return config @classmethod def create_default(cls) -> dict[str, Any]: diff --git a/vibe/core/llm/backend/anthropic.py b/vibe/core/llm/backend/anthropic.py index 581f44a..8c3241a 100644 --- a/vibe/core/llm/backend/anthropic.py +++ b/vibe/core/llm/backend/anthropic.py @@ -324,12 +324,6 @@ class AnthropicAdapter(APIAdapter): "prompt-caching-2024-07-31," "context-1m-2025-08-07" ) - THINKING_BUDGETS: ClassVar[dict[str, int]] = { - "low": 1024, - "medium": 10_000, - "high": 32_000, - "max": 128_000, - } DEFAULT_ADAPTIVE_MAX_TOKENS: ClassVar[int] = 32_768 DEFAULT_MAX_TOKENS = 8192 @@ -375,66 +369,32 @@ class AnthropicAdapter(APIAdapter): if last_block.get("type") in {"text", "image", "tool_result"}: last_block["cache_control"] = {"type": "ephemeral"} - # Anthropic models that require the `thinking={"type":"adaptive"}` + - # `output_config.effort` shape and reject the older - # `thinking={"type":"enabled","budget_tokens":...}` shape. Add new - # adaptive-only model families here as Anthropic ships them. - ADAPTIVE_MODEL_TAGS: ClassVar[frozenset[str]] = frozenset({"opus-4-6", "opus-4-7"}) - - # Anthropic models that have deprecated the `temperature` parameter and - # reject any payload containing it. Add new families here as Anthropic - # ships them. - TEMPERATURE_DEPRECATED_MODEL_TAGS: ClassVar[frozenset[str]] = frozenset({ - "opus-4-7" - }) - - @classmethod - def _is_adaptive_model(cls, model_name: str) -> bool: - return any(tag in model_name for tag in cls.ADAPTIVE_MODEL_TAGS) - - @classmethod - def _is_temperature_deprecated_model(cls, model_name: str) -> bool: - return any(tag in model_name for tag in cls.TEMPERATURE_DEPRECATED_MODEL_TAGS) - def _apply_thinking_config( self, payload: dict[str, Any], *, - model_name: str, messages: list[dict[str, Any]], - temperature: float, max_tokens: int | None, thinking: str, ) -> None: has_thinking = self._has_thinking_content(messages) thinking_level = thinking - temperature_deprecated = self._is_temperature_deprecated_model(model_name) if thinking_level == "off" and not has_thinking: - if not temperature_deprecated: - payload["temperature"] = temperature - if max_tokens is not None: - payload["max_tokens"] = max_tokens - else: - payload["max_tokens"] = self.DEFAULT_MAX_TOKENS + payload["max_tokens"] = ( + max_tokens if max_tokens is not None else self.DEFAULT_MAX_TOKENS + ) return # Resolve effective level: use config, or fallback to "medium" when # forced by thinking content in history effective_level = thinking_level if thinking_level != "off" else "medium" - if self._is_adaptive_model(model_name): - payload["thinking"] = {"type": "adaptive", "display": "summarized"} - payload["output_config"] = {"effort": effective_level} - default_max = self.DEFAULT_ADAPTIVE_MAX_TOKENS - else: - budget = self.THINKING_BUDGETS[effective_level] - payload["thinking"] = {"type": "enabled", "budget_tokens": budget} - default_max = budget + self.DEFAULT_MAX_TOKENS - - if not temperature_deprecated: - payload["temperature"] = 1 - payload["max_tokens"] = max_tokens if max_tokens is not None else default_max + payload["thinking"] = {"type": "adaptive", "display": "summarized"} + payload["output_config"] = {"effort": effective_level} + payload["max_tokens"] = ( + max_tokens if max_tokens is not None else self.DEFAULT_ADAPTIVE_MAX_TOKENS + ) def _build_payload( self, @@ -442,7 +402,6 @@ class AnthropicAdapter(APIAdapter): model_name: str, system_prompt: str | None, messages: list[dict[str, Any]], - temperature: float, tools: list[dict[str, Any]] | None, max_tokens: int | None, tool_choice: dict[str, Any] | None, @@ -452,12 +411,7 @@ class AnthropicAdapter(APIAdapter): payload: dict[str, Any] = {"model": model_name, "messages": messages} self._apply_thinking_config( - payload, - model_name=model_name, - messages=messages, - temperature=temperature, - max_tokens=max_tokens, - thinking=thinking, + payload, messages=messages, max_tokens=max_tokens, thinking=thinking ) if system_blocks := self._build_system_blocks(system_prompt): @@ -498,7 +452,6 @@ class AnthropicAdapter(APIAdapter): model_name=model_name, system_prompt=system_prompt, messages=converted_messages, - temperature=temperature, tools=converted_tools, max_tokens=max_tokens, tool_choice=converted_tool_choice, diff --git a/vibe/core/llm/backend/generic.py b/vibe/core/llm/backend/generic.py index 2ff3de5..12f6380 100644 --- a/vibe/core/llm/backend/generic.py +++ b/vibe/core/llm/backend/generic.py @@ -416,35 +416,6 @@ class GenericBackend: return yield json.loads(value.strip()) - async def count_tokens( - self, - *, - model: ModelConfig, - messages: Sequence[LLMMessage], - temperature: float = 0.0, - tools: list[AvailableTool] | None = None, - tool_choice: StrToolChoice | AvailableTool | None = None, - extra_headers: dict[str, str] | None = None, - metadata: dict[str, str] | None = None, - ) -> int: - probe_messages = list(messages) - if not probe_messages or probe_messages[-1].role != Role.user: - probe_messages.append(LLMMessage(role=Role.user, content="")) - - result = await self.complete( - model=model, - messages=probe_messages, - temperature=temperature, - tools=tools, - max_tokens=16, # Minimal amount for openrouter with openai models - tool_choice=tool_choice, - extra_headers=extra_headers, - ) - if result.usage is None: - raise ValueError("Missing usage in non streaming completion") - - return result.usage.prompt_tokens - async def close(self) -> None: if self._owns_client and self._client: await self._client.aclose() diff --git a/vibe/core/llm/backend/mistral.py b/vibe/core/llm/backend/mistral.py index 35ef95b..9af363d 100644 --- a/vibe/core/llm/backend/mistral.py +++ b/vibe/core/llm/backend/mistral.py @@ -426,29 +426,3 @@ class MistralBackend: has_tools=bool(tools), tool_choice=tool_choice, ) from e - - async def count_tokens( - self, - *, - model: ModelConfig, - messages: Sequence[LLMMessage], - temperature: float = 0.0, - tools: list[AvailableTool] | None = None, - tool_choice: StrToolChoice | AvailableTool | None = None, - extra_headers: dict[str, str] | None = None, - metadata: dict[str, str] | None = None, - ) -> int: - result = await self.complete( - model=model, - messages=messages, - temperature=temperature, - tools=tools, - max_tokens=1, - tool_choice=tool_choice, - extra_headers=extra_headers, - metadata=metadata, - ) - if result.usage is None: - raise ValueError("Missing usage in non streaming completion") - - return result.usage.prompt_tokens diff --git a/vibe/core/llm/backend/vertex.py b/vibe/core/llm/backend/vertex.py index 2b3657f..0a400fd 100644 --- a/vibe/core/llm/backend/vertex.py +++ b/vibe/core/llm/backend/vertex.py @@ -96,9 +96,7 @@ class VertexAnthropicAdapter(AnthropicAdapter): } self._apply_thinking_config( payload, - model_name=model_name, messages=converted_messages, - temperature=temperature, max_tokens=max_tokens, thinking=thinking, ) diff --git a/vibe/core/llm/types.py b/vibe/core/llm/types.py index c8cc8a8..81af577 100644 --- a/vibe/core/llm/types.py +++ b/vibe/core/llm/types.py @@ -14,7 +14,7 @@ class BackendLike(Protocol): """Port protocol for dependency-injectable LLM backends. Any backend used by AgentLoop should implement this async context manager - interface with `complete`, `complete_streaming` and `count_tokens` methods. + interface with `complete` and `complete_streaming` methods. """ async def __aenter__(self) -> BackendLike: ... @@ -92,35 +92,3 @@ class BackendLike(Protocol): BackendError: If the API request fails """ ... - - async def count_tokens( - self, - *, - model: ModelConfig, - messages: Sequence[LLMMessage], - temperature: float = 0.0, - tools: list[AvailableTool] | None, - tool_choice: StrToolChoice | AvailableTool | None = None, - extra_headers: dict[str, str] | None, - metadata: dict[str, str] | None = None, - ) -> int: - """Count the number of tokens in the prompt without generating a real response. - - This is useful for: - - Determining system prompt token count - - Checking context size after compaction - - Pre-flight token validation - - Args: - model: Model configuration - messages: List of messages to count tokens for - temperature: Sampling temperature - tools: Optional list of available tools - tool_choice: How to choose tools - extra_headers: Additional HTTP headers to include - metadata: Optional metadata to attach to the request - - Returns: - The number of prompt tokens - """ - ... diff --git a/vibe/core/skills/builtins/vibe.py b/vibe/core/skills/builtins/vibe.py index 61873c2..1c42260 100644 --- a/vibe/core/skills/builtins/vibe.py +++ b/vibe/core/skills/builtins/vibe.py @@ -79,6 +79,7 @@ enable_telemetry = true enable_update_checks = true enable_auto_update = true enable_notifications = true +enable_system_trust_store = false # Use OS trust store for outbound HTTPS api_timeout = 720.0 # API request timeout in seconds auto_compact_threshold = 200000 # Token count before auto-compaction @@ -267,6 +268,13 @@ browser_auth_base_url = "https://console.mistral.ai" browser_auth_api_base_url = "https://console.mistral.ai/api" ``` +Self-hosted deployments can point Vibe CLI upgrade and API-key links to their +Le Chat web deployment, where the Vibe API key is managed: + +```toml +vibe_base_url = "https://chat.mistral.ai" +``` + ### Hooks (Experimental) Hooks let users run shell commands automatically at specific points during a diff --git a/vibe/core/telemetry/send.py b/vibe/core/telemetry/send.py index 4ec99eb..11488b5 100644 --- a/vibe/core/telemetry/send.py +++ b/vibe/core/telemetry/send.py @@ -245,7 +245,6 @@ class TelemetryClient: self, *, nb_context_tokens_before: int, - nb_context_tokens_after: int, auto_compact_threshold: int, status: Literal["success", "failure", "cancelled"], session_id: str | None = None, @@ -253,7 +252,6 @@ class TelemetryClient: ) -> None: payload = { "nb_context_tokens_before": nb_context_tokens_before, - "nb_context_tokens_after": nb_context_tokens_after, "auto_compact_threshold": auto_compact_threshold, "status": status, } diff --git a/vibe/core/tools/mcp/__init__.py b/vibe/core/tools/mcp/__init__.py index 81cdccc..904cc32 100644 --- a/vibe/core/tools/mcp/__init__.py +++ b/vibe/core/tools/mcp/__init__.py @@ -11,6 +11,7 @@ from vibe.core.tools.mcp.tools import ( 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, ) @@ -26,6 +27,7 @@ __all__ = [ "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", ] diff --git a/vibe/core/tools/mcp/tools.py b/vibe/core/tools/mcp/tools.py index 51940d1..60885f3 100644 --- a/vibe/core/tools/mcp/tools.py +++ b/vibe/core/tools/mcp/tools.py @@ -9,11 +9,12 @@ from pathlib import Path import threading from typing import TYPE_CHECKING, Any, ClassVar, TextIO +import httpx from pydantic import BaseModel, ConfigDict, Field, field_validator from mcp import ClientSession from mcp.client.stdio import StdioServerParameters, stdio_client -from mcp.client.streamable_http import streamablehttp_client +from mcp.client.streamable_http import streamable_http_client from vibe.core.logger import logger from vibe.core.tools.base import ( BaseTool, @@ -25,11 +26,18 @@ from vibe.core.tools.base import ( from vibe.core.tools.mcp_sampling import MCPSamplingHandler from vibe.core.tools.ui import ToolResultDisplay, ToolUIData from vibe.core.types import ToolStreamEvent +from vibe.core.utils.http import build_ssl_context if TYPE_CHECKING: from vibe.core.types import ToolResultEvent +# Mirrors MCP's default Streamable HTTP timeout values while avoiding an import from +# mcp.shared._httpx_utils, which is an internal module. +_MCP_DEFAULT_TIMEOUT = 30.0 +_MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0 + + def _stderr_logger_thread(read_fd: int) -> None: with open(read_fd, "rb") as f: for line in iter(f.readline, b""): @@ -167,6 +175,15 @@ def _parse_call_result(server: str, tool: str, result_obj: Any) -> MCPToolResult return MCPToolResult(server=server, tool=tool, text=text, structured=None) +def create_vibe_mcp_http_client(headers: dict[str, str] | None) -> httpx.AsyncClient: + return httpx.AsyncClient( + follow_redirects=True, + headers=headers, + timeout=httpx.Timeout(_MCP_DEFAULT_TIMEOUT, read=_MCP_DEFAULT_SSE_READ_TIMEOUT), + verify=build_ssl_context(), + ) + + async def list_tools_http( url: str, *, @@ -174,11 +191,18 @@ async def list_tools_http( startup_timeout_sec: float | None = None, ) -> list[RemoteTool]: timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None - async with streamablehttp_client(url, headers=headers) as (read, write, _): - async with ClientSession(read, write, read_timeout_seconds=timeout) as session: - await session.initialize() - tools_resp = await session.list_tools() - return [RemoteTool.model_validate(t) for t in tools_resp.tools] + async with create_vibe_mcp_http_client(headers) as http_client: + async with streamable_http_client(url, http_client=http_client) as ( + read, + write, + _, + ): + async with ClientSession( + read, write, read_timeout_seconds=timeout + ) as session: + await session.initialize() + tools_resp = await session.list_tools() + return [RemoteTool.model_validate(t) for t in tools_resp.tools] async def call_tool_http( @@ -195,18 +219,23 @@ async def call_tool_http( timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None ) call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None - async with streamablehttp_client(url, headers=headers) as (read, write, _): - async with ClientSession( + async with create_vibe_mcp_http_client(headers) as http_client: + async with streamable_http_client(url, http_client=http_client) as ( read, write, - read_timeout_seconds=init_timeout, - sampling_callback=sampling_callback, - ) as session: - await session.initialize() - result = await session.call_tool( - tool_name, arguments, read_timeout_seconds=call_timeout - ) - return _parse_call_result(url, tool_name, result) + _, + ): + async with ClientSession( + read, + write, + read_timeout_seconds=init_timeout, + sampling_callback=sampling_callback, + ) as session: + await session.initialize() + result = await session.call_tool( + tool_name, arguments, read_timeout_seconds=call_timeout + ) + return _parse_call_result(url, tool_name, result) def create_mcp_http_proxy_tool_class( diff --git a/vibe/core/types.py b/vibe/core/types.py index d63f11a..21ea638 100644 --- a/vibe/core/types.py +++ b/vibe/core/types.py @@ -430,8 +430,6 @@ class CompactStartEvent(BaseEvent): class CompactEndEvent(BaseEvent): - old_context_tokens: int - new_context_tokens: int summary_length: int old_session_id: str | None = None new_session_id: str | None = None diff --git a/vibe/core/utils/__init__.py b/vibe/core/utils/__init__.py index b8f7904..5e3776b 100644 --- a/vibe/core/utils/__init__.py +++ b/vibe/core/utils/__init__.py @@ -12,9 +12,10 @@ from vibe.core.utils.concurrency import ( ConversationLimitException, run_sync, ) -from vibe.core.utils.display import compact_reduction_display +from vibe.core.utils.display import compact_complete_display from vibe.core.utils.http import ( build_ssl_context, + configure_ssl_context, get_server_url_from_api_base, get_user_agent, ) @@ -55,7 +56,8 @@ __all__ = [ "async_generator_retry", "async_retry", "build_ssl_context", - "compact_reduction_display", + "compact_complete_display", + "configure_ssl_context", "get_platform_display_name", "get_platform_id", "get_server_url_from_api_base", diff --git a/vibe/core/utils/display.py b/vibe/core/utils/display.py index 96f5b70..5ebb62d 100644 --- a/vibe/core/utils/display.py +++ b/vibe/core/utils/display.py @@ -3,23 +3,11 @@ from __future__ import annotations from vibe.core.session.session_id import shorten_session_id -def compact_reduction_display( - old_tokens: int | None, - new_tokens: int | None, - *, - old_session_id: str | None = None, - new_session_id: str | None = None, +def compact_complete_display( + old_session_id: str | None = None, new_session_id: str | None = None ) -> str: - message = "Compaction complete" - if old_tokens is not None and new_tokens is not None: - reduction = old_tokens - new_tokens - reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0 - message = ( - f"{message}: {old_tokens:,} → " - f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)" - ) - + message = "Compaction completed." if old_session_id is not None and new_session_id is not None: short_old = shorten_session_id(old_session_id) short_new = shorten_session_id(new_session_id) diff --git a/vibe/core/utils/http.py b/vibe/core/utils/http.py index 7616920..c0fbb39 100644 --- a/vibe/core/utils/http.py +++ b/vibe/core/utils/http.py @@ -6,14 +6,28 @@ import re import ssl import certifi +import truststore from vibe import __version__ from vibe.core.types import Backend +_use_system_trust_store = False + + +def configure_ssl_context(*, enable_system_trust_store: bool) -> None: + global _use_system_trust_store + if _use_system_trust_store == enable_system_trust_store: + return + _use_system_trust_store = enable_system_trust_store + build_ssl_context.cache_clear() + @functools.lru_cache(maxsize=1) def build_ssl_context() -> ssl.SSLContext: - ctx = ssl.create_default_context(cafile=certifi.where()) + if _use_system_trust_store: + ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + else: + ctx = ssl.create_default_context(cafile=certifi.where()) # Custom certs are additive so private-CA users don't lose public roots. ssl_cert_file = os.getenv("SSL_CERT_FILE") diff --git a/vibe/setup/auth/auth_state.py b/vibe/setup/auth/auth_state.py index ad60693..dc2f58f 100644 --- a/vibe/setup/auth/auth_state.py +++ b/vibe/setup/auth/auth_state.py @@ -128,6 +128,7 @@ def assess_auth_state( return _auth_state( AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV, can_use_active_provider=True, + sign_out_available=True, env_key=env_key, ) diff --git a/vibe/setup/onboarding/__init__.py b/vibe/setup/onboarding/__init__.py index 97cb93a..1883528 100644 --- a/vibe/setup/onboarding/__init__.py +++ b/vibe/setup/onboarding/__init__.py @@ -42,6 +42,7 @@ class OnboardingApp(App[str | None]): self._config = config self._provider = config.provider + self._vibe_base_url = config.vibe_base_url self._entrypoint_metadata = entrypoint_metadata self._browser_sign_in_success_delay = browser_sign_in_success_delay self._browser_sign_in_service_factory = self._resolve_browser_sign_in_factory( @@ -58,7 +59,11 @@ class OnboardingApp(App[str | None]): ThemeSelectionScreen(next_screen=theme_next), "theme_selection" ) self.install_screen( - ApiKeyScreen(self._provider, entrypoint_metadata=self._entrypoint_metadata), + ApiKeyScreen( + self._provider, + vibe_base_url=self._vibe_base_url, + entrypoint_metadata=self._entrypoint_metadata, + ), "api_key", ) if self._browser_sign_in_service_factory is not None: diff --git a/vibe/setup/onboarding/context.py b/vibe/setup/onboarding/context.py index 1eaadfc..de13912 100644 --- a/vibe/setup/onboarding/context.py +++ b/vibe/setup/onboarding/context.py @@ -12,6 +12,7 @@ from vibe.core.config._settings import ( DEFAULT_ACTIVE_MODEL, DEFAULT_MODELS, DEFAULT_PROVIDERS, + DEFAULT_VIBE_BASE_URL, ) from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.logger import logger @@ -29,6 +30,7 @@ def _default_model_payloads() -> list[dict[str, Any]]: class _OnboardingSnapshot(BaseModel): active_model: str = DEFAULT_ACTIVE_MODEL + vibe_base_url: str = DEFAULT_VIBE_BASE_URL providers: list[Any] = Field(default_factory=_default_provider_payloads) models: list[Any] = Field(default_factory=_default_model_payloads) @@ -97,6 +99,12 @@ def _load_onboarding_env_payload_for_fields( and (models := _find_env_value("VIBE_MODELS")) is not None ): payload["models"] = _ONBOARDING_LIST_ADAPTER.validate_json(models) + if ( + "vibe_base_url" in field_names + and (vibe_base_url := _find_env_value("VIBE_VIBE_BASE_URL")) is not None + ): + payload["vibe_base_url"] = vibe_base_url + return payload @@ -180,6 +188,7 @@ def _resolve_provider( @dataclass(frozen=True) class OnboardingContext: provider: ProviderConfig + vibe_base_url: str = DEFAULT_VIBE_BASE_URL @property def supports_browser_sign_in(self) -> bool: @@ -187,7 +196,9 @@ class OnboardingContext: @classmethod def from_config(cls, config: VibeConfig) -> OnboardingContext: - return cls(provider=config.get_active_provider()) + return cls( + provider=config.get_active_provider(), vibe_base_url=config.vibe_base_url + ) @classmethod def load(cls, **overrides: Any) -> OnboardingContext: @@ -198,7 +209,8 @@ class OnboardingContext: return cls( provider=_resolve_provider( active_model=snapshot.active_model, snapshot=snapshot - ) + ), + vibe_base_url=snapshot.vibe_base_url, ) except (RuntimeError, ValidationError, ValueError): logger.warning( diff --git a/vibe/setup/onboarding/screens/api_key.py b/vibe/setup/onboarding/screens/api_key.py index 824bd91..e72aa7b 100644 --- a/vibe/setup/onboarding/screens/api_key.py +++ b/vibe/setup/onboarding/screens/api_key.py @@ -12,7 +12,7 @@ from textual.widgets import Input, Link from vibe.cli.clipboard import copy_selection_to_clipboard from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic -from vibe.core.config import ProviderConfig +from vibe.core.config import DEFAULT_VIBE_BASE_URL, ProviderConfig from vibe.core.telemetry.types import EntrypointMetadata from vibe.setup.auth.api_key_persistence import ( persist_api_key, @@ -20,8 +20,8 @@ from vibe.setup.auth.api_key_persistence import ( ) from vibe.setup.onboarding.base import OnboardingScreen -# providers with a dedicated key creation page get a direct onboarding link -PROVIDER_HELP = {"mistral": ("https://console.mistral.ai/codestral/cli", "AI Studio")} +MISTRAL_PROVIDER_NAME = "mistral" +MISTRAL_PROVIDER_HELP_NAME = "Mistral Vibe" CONFIG_DOCS_URL = ( "https://github.com/mistralai/mistral-vibe?tab=readme-ov-file#configuration" ) @@ -39,17 +39,19 @@ class ApiKeyScreen(OnboardingScreen): self, provider: ProviderConfig | None = None, *, + vibe_base_url: str = DEFAULT_VIBE_BASE_URL, entrypoint_metadata: EntrypointMetadata | None = None, ) -> None: super().__init__() self.provider = resolve_api_key_provider(provider) + self._vibe_base_url = vibe_base_url self._entrypoint_metadata = entrypoint_metadata def _compose_provider_link(self) -> ComposeResult: - if (help_info := PROVIDER_HELP.get(self.provider.name)) is None: + if self.provider.name != MISTRAL_PROVIDER_NAME: return - help_url, _ = help_info + help_url = f"{self._vibe_base_url.rstrip('/')}/code/extensions?focus=key" yield Link(help_url, url=help_url, id="api-key-provider-link") def _compose_config_docs(self) -> ComposeResult: @@ -60,8 +62,11 @@ class ApiKeyScreen(OnboardingScreen): def compose(self) -> ComposeResult: provider_name = self.provider.name.capitalize() - help_info = PROVIDER_HELP.get(self.provider.name) - help_name = help_info[1] if help_info is not None else "your provider" + help_name = ( + MISTRAL_PROVIDER_HELP_NAME + if self.provider.name == MISTRAL_PROVIDER_NAME + else "your provider" + ) self.input_widget = Input( password=True, diff --git a/vibe/whats_new.md b/vibe/whats_new.md index 606633e..2d4afde 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -1,4 +1,4 @@ -# What's new in v2.12.1 +# What's new in v2.13.0 - **Custom compaction prompts**: Override the default `/compact` prompt by setting `compaction_prompt_id` and dropping a markdown file in `~/.vibe/prompts/` or `.vibe/prompts/`. - **Safer programmatic mode**: `-p` no longer auto-approves tool calls by default — pass `--auto-approve` to restore the previous behavior.