Co-authored-by: Bastien <bastien.baret@gmail.com>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Julien Legrand <72564015+JulienLGRD@users.noreply.github.com>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin
2026-04-14 10:33:15 +02:00
committed by GitHub
parent e9a9217cc8
commit e1a25caa52
85 changed files with 2830 additions and 594 deletions

3
.gitignore vendored
View File

@@ -200,5 +200,6 @@ result-*
tests/playground/*
.
# Profiler HTML reports (generated by vibe.cli.profiler)
# Profiler HTML/TXT reports (generated by vibe.cli.profiler)
*-profile.html
*-profile.txt

2
.vscode/launch.json vendored
View File

@@ -1,5 +1,5 @@
{
"version": "2.7.4",
"version": "2.7.5",
"configurations": [
{
"name": "ACP Server",

View File

@@ -134,6 +134,20 @@ guidelines:
- uv run script.py to run a script within the uv environment
- uv run pytest (or any other python tool) to run the tool within the uv environment
- title: "Safe File Reading"
description: >
When reading files from disk, prefer the helpers in `vibe.core.utils.io` over raw
`Path.read_text()`, `Path.read_bytes().decode()`, or `open()` calls:
- `read_safe(path)` — synchronous read with automatic encoding detection.
- `read_safe_async(path)` — async equivalent (anyio-based).
- `decode_safe(raw)` — decode an already-read `bytes` object.
These functions try UTF-8 first, then BOM detection, the locale encoding, and
`charset_normalizer` (lazily, only when cheaper candidates fail). They return a
`ReadSafeResult(text, encoding)` so callers always get valid `str` output without
having to handle encoding errors manually.
Use `raise_on_error=True` only when the caller must distinguish corrupt files from
valid ones; the default (`False`) replaces undecodable bytes with U+FFFD.
- title: "Imports in Cursor (no Pylance)"
description: >
Cursor's built-in Pyright does not offer the "Add import" quick fix (Ctrl+.). To add a missing import:

View File

@@ -5,6 +5,22 @@ 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.7.5] - 2026-04-14
### Changed
- Display detected files and LLM risks in trust folder dialog
- Text-to-speech via the Mistral SDK with telemetry tracking
- Deferred MCP and git I/O to background thread for faster CLI startup
- Made telemetry URL configurable
- Bumped Textual to 8.2.1
### Fixed
- Encoding detection fallback in `read_safe` for non-UTF-8 files
- Config saving logic cleanup
## [2.7.4] - 2026-04-09
### Added

View File

@@ -1,7 +1,7 @@
id = "mistral-vibe"
name = "Mistral Vibe"
description = "Mistral's open-source coding assistant"
version = "2.7.4"
version = "2.7.5"
schema_version = 1
authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe"
@@ -11,25 +11,25 @@ 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.7.4/vibe-acp-darwin-aarch64-2.7.4.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-darwin-aarch64-2.7.5.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-darwin-x86_64-2.7.4.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-darwin-x86_64-2.7.5.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-linux-aarch64-2.7.4.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-linux-aarch64-2.7.5.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-linux-x86_64-2.7.4.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-linux-x86_64-2.7.5.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-windows-aarch64-2.7.4.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-windows-aarch64-2.7.5.zip"
cmd = "./vibe-acp.exe"
[agent_servers.mistral-vibe.targets.windows-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-windows-x86_64-2.7.4.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-windows-x86_64-2.7.5.zip"
cmd = "./vibe-acp.exe"

View File

@@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
version = "2.7.4"
version = "2.7.5"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
@@ -30,11 +30,13 @@ dependencies = [
"agent-client-protocol==0.9.0",
"anyio>=4.12.0",
"cachetools>=5.5.0",
"charset-normalizer>=3.4.4",
"cryptography>=44.0.0,<=46.0.3",
"gitpython>=3.1.46",
"giturlparse>=0.14.0",
"google-auth>=2.0.0",
"httpx>=0.28.1",
"jsonpatch>=1.33",
"keyring>=25.6.0",
"markdownify>=1.2.2",
"mcp>=1.14.0",
@@ -53,7 +55,7 @@ dependencies = [
"requests>=2.20.0",
"rich>=14.0.0",
"sounddevice>=0.5.1",
"textual>=8.1.1",
"textual>=8.2.1",
"textual-speedups>=0.2.1",
"tomli-w>=1.2.0",
"tree-sitter>=0.25.2",
@@ -171,10 +173,12 @@ order-by-type = true
required-imports = ["from __future__ import annotations"]
[tool.ruff.lint.pylint]
max-public-methods = 25
max-positional-args = 10
max-statements = 50
max-branches = 15
max-locals = 15
max-args = 9
max-args = 10
max-returns = 6
max-nested-blocks = 5

View File

@@ -28,7 +28,7 @@ class TestACPInitialize:
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.4"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.5"
)
assert response.auth_methods == []
@@ -52,7 +52,7 @@ class TestACPInitialize:
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.4"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.5"
)
assert response.auth_methods is not None

View File

@@ -25,7 +25,7 @@ class TestEdit:
def test_returns_modified_content(self) -> None:
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
with patch("subprocess.run") as mock_run:
with patch("pathlib.Path.read_text", return_value="modified"):
with patch("pathlib.Path.read_bytes", return_value=b"modified"):
with patch("pathlib.Path.unlink"):
editor = ExternalEditor()
result = editor.edit("original")
@@ -35,7 +35,7 @@ class TestEdit:
def test_returns_none_when_content_unchanged(self) -> None:
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
with patch("subprocess.run"):
with patch("pathlib.Path.read_text", return_value="same"):
with patch("pathlib.Path.read_bytes", return_value=b"same"):
with patch("pathlib.Path.unlink"):
editor = ExternalEditor()
result = editor.edit("same")
@@ -44,7 +44,7 @@ class TestEdit:
def test_strips_trailing_whitespace(self) -> None:
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
with patch("subprocess.run"):
with patch("pathlib.Path.read_text", return_value="content\n\n"):
with patch("pathlib.Path.read_bytes", return_value=b"content\n\n"):
with patch("pathlib.Path.unlink"):
editor = ExternalEditor()
result = editor.edit("original")
@@ -53,7 +53,7 @@ class TestEdit:
def test_handles_editor_with_args(self) -> None:
with patch.dict("os.environ", {"VISUAL": "code --wait"}, clear=True):
with patch("subprocess.run") as mock_run:
with patch("pathlib.Path.read_text", return_value="edited"):
with patch("pathlib.Path.read_bytes", return_value=b"edited"):
with patch("pathlib.Path.unlink"):
editor = ExternalEditor()
editor.edit("original")

View File

@@ -39,6 +39,8 @@ def get_base_config() -> dict[str, Any]:
"name": "mistral",
"api_base": "https://api.mistral.ai/v1",
"api_key_env_var": "MISTRAL_API_KEY",
"browser_auth_base_url": "https://console.mistral.ai",
"browser_auth_api_base_url": "https://console.mistral.ai/api",
"backend": "mistral",
}
],

View File

@@ -1,13 +1,20 @@
from __future__ import annotations
import json
from pathlib import Path
import tomllib
from typing import Literal, TypedDict, Unpack
import pytest
import tomli_w
from tests.conftest import build_test_vibe_config
from vibe.core.config import ModelConfig, VibeConfig
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
from vibe.core.config._settings import (
DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL,
DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL,
DEFAULT_PROVIDERS,
)
from vibe.core.config.harness_files import (
HarnessFilesManager,
init_harness_files_manager,
@@ -15,6 +22,66 @@ 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.setup.onboarding.context import OnboardingContext
class _ProviderConfigOverrides(TypedDict, total=False):
api_key_env_var: str
browser_auth_base_url: str | None
browser_auth_api_base_url: str | None
api_style: str
backend: Backend
reasoning_field_name: str
project_id: str
region: str
class _ModelConfigOverrides(TypedDict, total=False):
temperature: float
input_price: float
output_price: float
thinking: Literal["off", "low", "medium", "high"]
auto_compact_threshold: int
def _default_provider(name: str) -> ProviderConfig:
return next(provider for provider in DEFAULT_PROVIDERS if provider.name == name)
def _custom_provider(**overrides: Unpack[_ProviderConfigOverrides]) -> ProviderConfig:
return ProviderConfig(
name="custom-provider", api_base="https://custom.example/v1", **overrides
)
def _custom_model(**overrides: Unpack[_ModelConfigOverrides]) -> ModelConfig:
return ModelConfig(
name="custom-model",
provider="custom-provider",
alias="custom-model",
**overrides,
)
def _custom_provider_payload(**overrides: object) -> dict[str, object]:
payload: dict[str, object] = {
"name": "custom-provider",
"api_base": "https://custom.example/v1",
"api_key_env_var": "CUSTOM_API_KEY",
}
payload.update(overrides)
return payload
def _custom_model_payload(**overrides: object) -> dict[str, object]:
payload: dict[str, object] = {
"name": "custom-model",
"provider": "custom-provider",
"alias": "custom-model",
}
payload.update(overrides)
return payload
class TestResolveConfigFile:
@@ -86,6 +153,51 @@ class TestResolveConfigFile:
assert mgr.config_file == VIBE_HOME.path / "config.toml"
class TestSaveUpdates:
def test_merges_nested_tool_updates_without_materializing_defaults(
self, config_dir: Path
) -> None:
config_file = config_dir / "config.toml"
data = {"tools": {"bash": {"default_timeout": 600}}}
with config_file.open("wb") as f:
tomli_w.dump(data, f)
VibeConfig.save_updates({"tools": {"bash": {"permission": "always"}}})
with config_file.open("rb") as f:
result = tomllib.load(f)
assert result == {
"tools": {"bash": {"default_timeout": 600, "permission": "always"}}
}
def test_replaces_lists_instead_of_unioning_them(self, config_dir: Path) -> None:
config_file = config_dir / "config.toml"
data = {"installed_agents": ["lean", "other"]}
with config_file.open("wb") as f:
tomli_w.dump(data, f)
VibeConfig.save_updates({"installed_agents": ["lean"]})
with config_file.open("rb") as f:
result = tomllib.load(f)
assert result["installed_agents"] == ["lean"]
def test_prunes_nested_none_values_before_writing(self, config_dir: Path) -> None:
config_file = config_dir / "config.toml"
data = {"tools": {"bash": {"default_timeout": 600, "permission": "always"}}}
with config_file.open("wb") as f:
tomli_w.dump(data, f)
VibeConfig.save_updates({"tools": {"bash": {"permission": None}}})
with config_file.open("rb") as f:
result = tomllib.load(f)
assert result == {"tools": {"bash": {"default_timeout": 600}}}
class TestMigrateRemovesFindFromBashAllowlist:
def test_removes_find_from_config_file(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -177,6 +289,472 @@ class TestAutoCompactThresholdFallback:
assert cfg2.get_active_model().auto_compact_threshold == 75_000
class TestDefaultProviderConfig:
def test_default_mistral_provider_is_mistral_backend(self) -> None:
provider = _default_provider("mistral")
assert provider.name == "mistral"
assert provider.backend.value == "mistral"
assert provider.browser_auth_base_url == DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL
assert (
provider.browser_auth_api_base_url
== DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL
)
assert provider.supports_browser_sign_in is True
def test_non_mistral_provider_does_not_inherit_browser_auth_defaults(self) -> None:
provider = _default_provider("llamacpp")
assert provider.browser_auth_base_url is None
assert provider.browser_auth_api_base_url is None
assert provider.supports_browser_sign_in is False
class TestMistralBrowserAuthConfig:
def test_provider_browser_auth_urls_are_dumped_when_set(self) -> None:
cfg = build_test_vibe_config()
provider = cfg.get_provider_for_model(cfg.get_active_model())
dumped = cfg.model_dump(mode="json")
assert provider.browser_auth_base_url == DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL
assert (
provider.browser_auth_api_base_url
== DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL
)
assert (
dumped["providers"][0]["browser_auth_base_url"]
== DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL
)
assert (
dumped["providers"][0]["browser_auth_api_base_url"]
== DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL
)
def test_legacy_explicit_mistral_provider_backfills_browser_auth_urls_without_changing_backend(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
with config_file.open("wb") as f:
tomli_w.dump(
{
"active_model": "devstral-2",
"providers": [
{
"name": "mistral",
"api_base": "https://api.mistral.ai/v1",
"api_key_env_var": "MISTRAL_API_KEY",
"reasoning_field_name": "thoughts",
}
],
"models": [
{
"name": "mistral-vibe-cli-latest",
"provider": "mistral",
"alias": "devstral-2",
}
],
},
f,
)
reset_harness_files_manager()
init_harness_files_manager("user")
context = OnboardingContext.load()
assert context.provider.name == "mistral"
assert context.provider.backend.value == "generic"
assert context.provider.reasoning_field_name == "thoughts"
assert (
context.provider.browser_auth_base_url
== DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL
)
assert (
context.provider.browser_auth_api_base_url
== DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL
)
assert context.supports_browser_sign_in is True
def test_legacy_explicit_mistral_provider_backfills_only_missing_browser_auth_url(
self,
) -> None:
provider = ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
browser_auth_base_url="https://custom-console.example",
)
assert provider.backend.value == "generic"
assert provider.browser_auth_base_url == "https://custom-console.example"
assert (
provider.browser_auth_api_base_url
== DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL
)
assert provider.supports_browser_sign_in is True
def test_legacy_mistral_provider_keeps_browser_sign_in_after_round_trip(
self,
) -> None:
provider = ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
)
reloaded_provider = ProviderConfig.model_validate(
provider.model_dump(mode="json")
)
assert reloaded_provider.supports_browser_sign_in is True
def test_explicit_generic_mistral_provider_does_not_get_browser_auth_defaults(
self,
) -> None:
provider = ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.GENERIC,
)
assert provider.backend.value == "generic"
assert provider.browser_auth_base_url is None
assert provider.browser_auth_api_base_url is None
assert provider.supports_browser_sign_in is False
def test_custom_provider_browser_auth_urls_round_trip(self) -> None:
custom_provider = _custom_provider(
browser_auth_base_url="https://custom.example/sign-in",
browser_auth_api_base_url="https://custom.example/api",
backend=Backend.MISTRAL,
)
cfg = build_test_vibe_config(
active_model="custom-model",
providers=[custom_provider],
models=[_custom_model()],
)
dumped = cfg.model_dump(mode="json")
reloaded_provider = ProviderConfig.model_validate(dumped["providers"][0])
assert (
reloaded_provider.browser_auth_base_url == "https://custom.example/sign-in"
)
assert (
reloaded_provider.browser_auth_api_base_url == "https://custom.example/api"
)
assert reloaded_provider.supports_browser_sign_in is True
def test_custom_mistral_provider_without_browser_auth_urls_is_not_capable(
self,
) -> None:
provider = _custom_provider(backend=Backend.MISTRAL)
assert provider.browser_auth_base_url is None
assert provider.browser_auth_api_base_url is None
assert provider.supports_browser_sign_in is False
def test_non_mistral_provider_with_browser_auth_urls_is_not_capable(self) -> None:
provider = _custom_provider(
browser_auth_base_url="https://custom.example/sign-in",
browser_auth_api_base_url="https://custom.example/api",
)
assert provider.supports_browser_sign_in is False
class TestOnboardingContextResolution:
def test_load_uses_explicit_overrides_when_harness_manager_is_uninitialized(
self,
) -> None:
reset_harness_files_manager()
context = OnboardingContext.load(
active_model="custom-model",
providers=[_custom_provider_payload()],
models=[_custom_model_payload()],
)
assert context.provider.name == "custom-provider"
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
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_PROVIDERS",
json.dumps([
{
"name": "env-provider",
"api_base": "https://env.example/v1",
"api_key_env_var": "ENV_API_KEY",
}
]),
)
monkeypatch.setenv(
"VIBE_MODELS",
json.dumps([
{"name": "env-model", "provider": "env-provider", "alias": "env-model"}
]),
)
context = OnboardingContext.load()
assert context.provider.name == "env-provider"
assert context.provider.api_key_env_var == "ENV_API_KEY"
def test_load_prefers_explicit_overrides_over_toml_and_env(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
with config_file.open("wb") as file:
tomli_w.dump(
{
"active_model": "toml-model",
"providers": [
{
"name": "toml-provider",
"api_base": "https://toml.example/v1",
"api_key_env_var": "TOML_API_KEY",
}
],
"models": [
{
"name": "toml-model",
"provider": "toml-provider",
"alias": "toml-model",
}
],
},
file,
)
monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model")
monkeypatch.setenv(
"VIBE_PROVIDERS",
json.dumps([
{
"name": "env-provider",
"api_base": "https://env.example/v1",
"api_key_env_var": "ENV_API_KEY",
}
]),
)
monkeypatch.setenv(
"VIBE_MODELS",
json.dumps([
{"name": "env-model", "provider": "env-provider", "alias": "env-model"}
]),
)
reset_harness_files_manager()
init_harness_files_manager("user")
context = OnboardingContext.load(
active_model="custom-model",
providers=[_custom_provider_payload()],
models=[_custom_model_payload()],
)
assert context.provider.name == "custom-provider"
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
def test_load_accepts_typed_provider_and_model_overrides(self) -> None:
context = OnboardingContext.load(
active_model="custom-model",
providers=[_custom_provider(api_key_env_var="CUSTOM_API_KEY")],
models=[_custom_model()],
)
assert context.provider.name == "custom-provider"
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
def test_load_preserves_explicit_overrides_when_onboarding_toml_is_invalid(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
config_file.write_text("invalid = [", encoding="utf-8")
reset_harness_files_manager()
init_harness_files_manager("user")
context = OnboardingContext.load(
active_model="custom-model",
providers=[_custom_provider_payload()],
models=[_custom_model_payload()],
)
assert context.provider.name == "custom-provider"
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
def test_load_preserves_explicit_provider_and_model_overrides_when_toml_is_invalid(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
config_file.write_text("invalid = [", encoding="utf-8")
reset_harness_files_manager()
init_harness_files_manager("user")
context = OnboardingContext.load(
providers=[_custom_provider_payload()],
models=[_custom_model_payload(alias="devstral-2")],
)
assert context.provider.name == "custom-provider"
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
def test_load_preserves_explicit_provider_override_when_toml_is_invalid(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
config_file.write_text("invalid = [", encoding="utf-8")
reset_harness_files_manager()
init_harness_files_manager("user")
context = OnboardingContext.load(
active_model="custom-model", providers=[_custom_provider_payload()]
)
assert context.provider.name == "custom-provider"
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
def test_load_preserves_explicit_overrides_when_onboarding_env_is_invalid(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
reset_harness_files_manager()
monkeypatch.setenv("VIBE_PROVIDERS", "not-json")
context = OnboardingContext.load(
active_model="custom-model",
providers=[_custom_provider_payload()],
models=[_custom_model_payload()],
)
assert context.provider.name == "custom-provider"
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
def test_load_preserves_explicit_provider_override_when_onboarding_env_is_invalid(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
reset_harness_files_manager()
monkeypatch.setenv("VIBE_MODELS", "not-json")
context = OnboardingContext.load(
active_model="custom-model", providers=[_custom_provider_payload()]
)
assert context.provider.name == "custom-provider"
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
def test_load_uses_valid_active_provider_when_unrelated_provider_is_malformed(
self,
) -> None:
context = OnboardingContext.load(
active_model="custom-model",
providers=[
_custom_provider_payload(),
{"name": "broken-provider", "backend": "mistral"},
],
models=[_custom_model_payload()],
)
assert context.provider.name == "custom-provider"
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
def test_load_uses_valid_active_provider_when_unrelated_model_is_malformed(
self,
) -> None:
context = OnboardingContext.load(
active_model="custom-model",
providers=[_custom_provider_payload()],
models=[
_custom_model_payload(),
{"name": "broken-model", "alias": "broken-model"},
],
)
assert context.provider.name == "custom-provider"
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
def test_load_uses_single_valid_provider_when_no_matching_model_exists(
self,
) -> None:
context = OnboardingContext.load(
active_model="custom-model", providers=[_custom_provider_payload()]
)
assert context.provider.name == "custom-provider"
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
def test_load_preserves_browser_sign_in_for_valid_active_provider_with_unrelated_invalid_entry(
self,
) -> None:
context = OnboardingContext.load(
active_model="custom-model",
providers=[
_custom_provider_payload(
browser_auth_base_url="https://custom.example/sign-in",
browser_auth_api_base_url="https://custom.example/api",
backend="mistral",
),
{"name": "broken-provider", "backend": "mistral"},
],
models=[_custom_model_payload()],
)
assert context.provider.name == "custom-provider"
assert context.supports_browser_sign_in is True
def test_load_falls_back_when_active_provider_is_invalid(self) -> None:
context = OnboardingContext.load(
active_model="custom-model",
providers=[{"name": "custom-provider", "backend": "mistral"}],
models=[_custom_model_payload()],
)
assert context.provider.name == "mistral"
def test_load_falls_back_when_no_valid_provider_model_pair_exists(self) -> None:
context = OnboardingContext.load(
active_model="broken-model",
providers=[{"name": "broken-provider", "backend": "mistral"}],
models=[{"name": "broken-model", "alias": "broken-model"}],
)
assert context.provider.name == "mistral"
def test_load_falls_back_when_onboarding_toml_is_invalid(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
config_file = tmp_path / "config.toml"
config_file.write_text("invalid = [", encoding="utf-8")
reset_harness_files_manager()
init_harness_files_manager("user")
context = OnboardingContext.load()
assert context.provider.name == "mistral"
def test_load_falls_back_when_onboarding_env_payload_is_invalid(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
reset_harness_files_manager()
monkeypatch.setenv("VIBE_PROVIDERS", "not-json")
context = OnboardingContext.load()
assert context.provider.name == "mistral"
class TestCompactionModel:
def test_get_compaction_model_returns_active_when_unset(self) -> None:
cfg = build_test_vibe_config()
@@ -224,3 +802,68 @@ class TestCompactionModel:
cfg = build_test_vibe_config()
dumped = cfg.model_dump()
assert "compaction_model" not in dumped
class TestGetMistralProvider:
def test_returns_active_provider_when_it_is_mistral(self) -> None:
cfg = build_test_vibe_config()
provider = cfg.get_mistral_provider()
active = cfg.get_provider_for_model(cfg.get_active_model())
assert provider is active
assert provider is not None
assert provider.backend == Backend.MISTRAL
def test_falls_back_to_first_mistral_provider_when_active_is_not_mistral(
self,
) -> None:
mistral_provider = ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)
llamacpp_provider = ProviderConfig(
name="llamacpp", api_base="http://127.0.0.1:8080/v1", api_key_env_var=""
)
llamacpp_model = ModelConfig(
name="llama-local", provider="llamacpp", alias="llama-local"
)
cfg = build_test_vibe_config(
providers=[llamacpp_provider, mistral_provider],
models=[llamacpp_model],
active_model="llama-local",
)
provider = cfg.get_mistral_provider()
assert provider is mistral_provider
def test_returns_none_when_no_mistral_provider(self) -> None:
llamacpp_provider = ProviderConfig(
name="llamacpp", api_base="http://127.0.0.1:8080/v1", api_key_env_var=""
)
llamacpp_model = ModelConfig(
name="llama-local", provider="llamacpp", alias="llama-local"
)
cfg = build_test_vibe_config(
providers=[llamacpp_provider],
models=[llamacpp_model],
active_model="llama-local",
)
assert cfg.get_mistral_provider() is None
def test_falls_back_to_iterating_when_active_model_is_misconfigured(self) -> None:
mistral_provider = ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)
llamacpp_model = ModelConfig(
name="llama-local", provider="llamacpp", alias="llama-local"
)
cfg = build_test_vibe_config(
providers=[mistral_provider],
models=[llamacpp_model],
active_model="llama-local",
)
provider = cfg.get_mistral_provider()
assert provider is mistral_provider

View File

@@ -5,131 +5,164 @@ from pathlib import Path
from vibe.core.paths._local_config_walk import (
_MAX_DIRS,
WALK_MAX_DEPTH,
has_config_dirs_nearby,
walk_local_config_dirs_all,
walk_local_config_dirs,
)
class TestBoundedWalk:
class TestWalkTools:
def test_finds_config_at_root(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
tools, skills, agents = walk_local_config_dirs_all(tmp_path)
assert tmp_path / ".vibe" / "tools" in tools
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" / "tools" in result.tools
def test_finds_config_within_depth_limit(self, tmp_path: Path) -> None:
nested = tmp_path
for i in range(WALK_MAX_DEPTH):
nested = nested / f"level{i}"
(nested / ".vibe" / "skills").mkdir(parents=True)
_, skills, _ = walk_local_config_dirs_all(tmp_path)
assert nested / ".vibe" / "skills" in skills
result = walk_local_config_dirs(tmp_path)
assert nested.resolve() / ".vibe" / "skills" in result.skills
def test_does_not_find_config_beyond_depth_limit(self, tmp_path: Path) -> None:
nested = tmp_path
for i in range(WALK_MAX_DEPTH + 1):
nested = nested / f"level{i}"
(nested / ".vibe" / "tools").mkdir(parents=True)
tools, skills, agents = walk_local_config_dirs_all(tmp_path)
assert not tools
assert not skills
assert not agents
result = walk_local_config_dirs(tmp_path)
assert not result.tools
assert not result.skills
assert not result.agents
def test_respects_dir_count_limit(self, tmp_path: Path) -> None:
# Create more directories than _MAX_DIRS at depth 1
for i in range(_MAX_DIRS + 10):
(tmp_path / f"dir{i:05d}").mkdir()
# Place config in a directory that would be scanned late
(tmp_path / "zzz_last" / ".vibe" / "tools").mkdir(parents=True)
tools, _, _ = walk_local_config_dirs_all(tmp_path)
# The walk should stop before visiting all dirs.
# Whether zzz_last is found depends on sort order and limit,
# but total visited dirs should be bounded.
# We just verify no crash and the function returns.
assert isinstance(tools, tuple)
result = walk_local_config_dirs(tmp_path)
assert isinstance(result.tools, tuple)
def test_skips_ignored_directories(self, tmp_path: Path) -> None:
(tmp_path / "node_modules" / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
tools, _, _ = walk_local_config_dirs_all(tmp_path)
assert tools == (tmp_path / ".vibe" / "tools",)
result = walk_local_config_dirs(tmp_path)
assert result.tools == (tmp_path.resolve() / ".vibe" / "tools",)
def test_skips_dot_directories(self, tmp_path: Path) -> None:
(tmp_path / ".hidden" / ".vibe" / "tools").mkdir(parents=True)
tools, _, _ = walk_local_config_dirs_all(tmp_path)
assert not tools
result = walk_local_config_dirs(tmp_path)
assert not result.tools
def test_preserves_alphabetical_ordering(self, tmp_path: Path) -> None:
(tmp_path / "bbb" / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / "aaa" / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
tools, _, _ = walk_local_config_dirs_all(tmp_path)
assert tools == (
tmp_path / ".vibe" / "tools",
tmp_path / "aaa" / ".vibe" / "tools",
tmp_path / "bbb" / ".vibe" / "tools",
result = walk_local_config_dirs(tmp_path)
resolved = tmp_path.resolve()
assert result.tools == (
resolved / ".vibe" / "tools",
resolved / "aaa" / ".vibe" / "tools",
resolved / "bbb" / ".vibe" / "tools",
)
def test_finds_agents_skills(self, tmp_path: Path) -> None:
(tmp_path / ".agents" / "skills").mkdir(parents=True)
_, skills, _ = walk_local_config_dirs_all(tmp_path)
assert tmp_path / ".agents" / "skills" in skills
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".agents" / "skills" in result.skills
def test_finds_all_config_types(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
(tmp_path / ".vibe" / "agents").mkdir(parents=True)
(tmp_path / ".agents" / "skills").mkdir(parents=True)
tools, skills, agents = walk_local_config_dirs_all(tmp_path)
assert tmp_path / ".vibe" / "tools" in tools
assert tmp_path / ".vibe" / "skills" in skills
assert tmp_path / ".vibe" / "agents" in agents
assert tmp_path / ".agents" / "skills" in skills
result = walk_local_config_dirs(tmp_path)
resolved = tmp_path.resolve()
assert resolved / ".vibe" / "tools" in result.tools
assert resolved / ".vibe" / "skills" in result.skills
assert resolved / ".vibe" / "agents" in result.agents
assert resolved / ".agents" / "skills" in result.skills
class TestHasConfigDirsNearby:
def test_returns_true_when_vibe_tools_exist(self, tmp_path: Path) -> None:
class TestWalkConfigDirs:
def test_finds_vibe_with_tools(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
assert has_config_dirs_nearby(tmp_path) is True
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" in result.config_dirs
def test_returns_true_when_vibe_skills_exist(self, tmp_path: Path) -> None:
def test_finds_vibe_with_skills(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
assert has_config_dirs_nearby(tmp_path) is True
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" in result.config_dirs
def test_returns_true_when_agents_skills_exist(self, tmp_path: Path) -> None:
def test_finds_agents_with_skills(self, tmp_path: Path) -> None:
(tmp_path / ".agents" / "skills").mkdir(parents=True)
assert has_config_dirs_nearby(tmp_path) is True
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".agents" in result.config_dirs
def test_returns_false_when_empty(self, tmp_path: Path) -> None:
assert has_config_dirs_nearby(tmp_path) is False
def test_returns_false_for_vibe_dir_without_subdirs(self, tmp_path: Path) -> None:
def test_ignores_empty_vibe_dir(self, tmp_path: Path) -> None:
(tmp_path / ".vibe").mkdir()
assert has_config_dirs_nearby(tmp_path) is False
result = walk_local_config_dirs(tmp_path)
assert result.config_dirs == ()
def test_returns_true_for_shallow_nested(self, tmp_path: Path) -> None:
def test_ignores_empty_agents_dir(self, tmp_path: Path) -> None:
(tmp_path / ".agents").mkdir()
result = walk_local_config_dirs(tmp_path)
assert result.config_dirs == ()
def test_returns_empty_when_empty(self, tmp_path: Path) -> None:
result = walk_local_config_dirs(tmp_path)
assert result.config_dirs == ()
def test_finds_shallow_nested(self, tmp_path: Path) -> None:
(tmp_path / "sub" / ".vibe" / "skills").mkdir(parents=True)
assert has_config_dirs_nearby(tmp_path) is True
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / "sub" / ".vibe" in result.config_dirs
def test_returns_true_at_depth_2(self, tmp_path: Path) -> None:
def test_finds_at_depth_2(self, tmp_path: Path) -> None:
(tmp_path / "a" / "b" / ".agents" / "skills").mkdir(parents=True)
assert has_config_dirs_nearby(tmp_path) is True
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / "a" / "b" / ".agents" in result.config_dirs
def test_returns_false_beyond_default_depth(self, tmp_path: Path) -> None:
def test_returns_empty_beyond_default_depth(self, tmp_path: Path) -> None:
(tmp_path / "a" / "b" / "c" / "d" / "e" / ".vibe" / "tools").mkdir(parents=True)
assert has_config_dirs_nearby(tmp_path) is False
result = walk_local_config_dirs(tmp_path)
assert result.config_dirs == ()
def test_custom_depth(self, tmp_path: Path) -> None:
(tmp_path / "a" / "b" / "c" / "d" / "e" / ".vibe" / "tools").mkdir(parents=True)
assert has_config_dirs_nearby(tmp_path, max_depth=5) is True
result = walk_local_config_dirs(tmp_path, max_depth=5)
assert (
tmp_path.resolve() / "a" / "b" / "c" / "d" / "e" / ".vibe"
in result.config_dirs
)
def test_early_exit_on_first_match(self, tmp_path: Path) -> None:
# Create many dirs but put config early; function should return quickly
def test_finds_match_among_many_dirs(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
for i in range(100):
(tmp_path / f"dir{i}").mkdir()
assert has_config_dirs_nearby(tmp_path) is True
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" in result.config_dirs
def test_skips_ignored_directories(self, tmp_path: Path) -> None:
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
assert has_config_dirs_nearby(tmp_path) is False
result = walk_local_config_dirs(tmp_path)
assert result.config_dirs == ()
def test_finds_vibe_with_prompts(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "prompts").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" in result.config_dirs
def test_finds_vibe_with_config_toml(self, tmp_path: Path) -> None:
(tmp_path / ".vibe").mkdir()
(tmp_path / ".vibe" / "config.toml").write_text("")
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" in result.config_dirs
def test_finds_multiple_config_dirs(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
(tmp_path / ".agents" / "skills").mkdir(parents=True)
(tmp_path / "sub" / ".vibe" / "tools").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
resolved = tmp_path.resolve()
assert resolved / ".vibe" in result.config_dirs
assert resolved / ".agents" in result.config_dirs
assert resolved / "sub" / ".vibe" in result.config_dirs

View File

@@ -724,3 +724,46 @@ def test_working_completed_with_tool_call_id_emits_error_result() -> None:
assert len(result_events) == 1
assert result_events[0].error is not None
assert result_events[0].tool_call_id == "call-write-err"
def test_json_patch_with_array_index_preserves_list_structure() -> None:
loop = _make_loop()
started = _started(
"msg-1",
"assistant_message",
{"contentChunks": [{"type": "text", "text": "Hello"}]},
)
in_progress = _in_progress(
"msg-1",
"assistant_message",
[JSONPatchReplace(path="/contentChunks/0/text", value="Hello world")],
)
loop._consume_workflow_event(started)
progress_events = loop._consume_workflow_event(in_progress)
assert len(progress_events) == 1
assert isinstance(progress_events[0], AssistantEvent)
assert progress_events[0].content == " world"
def test_steer_input_events_are_suppressed() -> None:
loop = _make_loop()
steer_started = _started(
"steer-1",
"wait_for_input",
{"input_schema": {"title": "ChatInput"}, "label": "Send a message to steer..."},
)
steer_completed = _completed(
"steer-1",
"wait_for_input",
{
"input_schema": {"title": "ChatInput"},
"label": "Send a message to steer...",
"input": None,
},
)
assert loop._consume_workflow_event(steer_started) == []
assert loop._consume_workflow_event(steer_completed) == []
assert loop._translator.pending_input_request is None

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from pathlib import Path
import pytest
from tests.mock.utils import collect_result
from vibe.core.tools.base import BaseToolState
from vibe.core.tools.builtins.search_replace import (
SearchReplace,
SearchReplaceArgs,
SearchReplaceConfig,
)
@pytest.mark.asyncio
async def test_search_replace_rewrites_with_detected_encoding(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
path = tmp_path / "utf16.txt"
original = "line one café\nline two été\n"
path.write_bytes(original.encode("utf-16"))
tool = SearchReplace(
config_getter=lambda: SearchReplaceConfig(), state=BaseToolState()
)
patch = "<<<<<<< SEARCH\nline one café\n=======\nLINE ONE CAFÉ\n>>>>>>> REPLACE"
await collect_result(
tool.run(SearchReplaceArgs(file_path=str(path), content=patch))
)
assert path.read_bytes().startswith(b"\xff\xfe")
assert path.read_text(encoding="utf-16") == "LINE ONE CAFÉ\nline two été\n"

View File

@@ -10,7 +10,7 @@ from tests.conftest import build_test_vibe_config
from tests.stubs.fake_tool import FakeTool, FakeToolArgs
from vibe.core.agent_loop import ToolDecision, ToolExecutionResponse
from vibe.core.llm.format import ResolvedToolCall
from vibe.core.telemetry.send import DATALAKE_EVENTS_URL, TelemetryClient
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.tools.base import BaseTool, ToolPermission
from vibe.core.types import Backend
from vibe.core.utils import get_user_agent
@@ -44,6 +44,13 @@ def _run_telemetry_tasks() -> None:
class TestTelemetryClient:
def test_send_telemetry_event_swallows_config_getter_value_error(self) -> None:
def _raise_config_error() -> Any:
raise ValueError("config not ready")
client = TelemetryClient(config_getter=_raise_config_error)
client.send_telemetry_event("vibe.test", {})
def test_send_telemetry_event_does_nothing_when_api_key_is_none(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -104,7 +111,7 @@ class TestTelemetryClient:
await client.aclose()
mock_post.assert_called_once_with(
DATALAKE_EVENTS_URL,
"https://api.mistral.ai/v1/datalake/events",
json={"event": "vibe.test_event", "properties": {"key": "value"}},
headers={
"Content-Type": "application/json",
@@ -302,7 +309,7 @@ class TestTelemetryClient:
await client.aclose()
mock_post.assert_called_once_with(
DATALAKE_EVENTS_URL,
"https://api.mistral.ai/v1/datalake/events",
json={
"event": "vibe.test_event",
"properties": {"session_id": session_id, "key": "value"},
@@ -336,7 +343,7 @@ class TestTelemetryClient:
await client.aclose()
mock_post.assert_called_once_with(
DATALAKE_EVENTS_URL,
"https://api.mistral.ai/v1/datalake/events",
json={"event": "vibe.test_event", "properties": {"key": "value"}},
headers={
"Content-Type": "application/json",
@@ -414,3 +421,109 @@ class TestTelemetryClient:
assert len(telemetry_events) == 1
assert "correlation_id" not in telemetry_events[0]
def test_telemetry_url_custom_provider_config(self) -> None:
from vibe.core.config import ProviderConfig
from vibe.core.types import Backend
custom_api_base = "https://api.custom.host/v2"
config = build_test_vibe_config(
enable_telemetry=True,
providers=[
ProviderConfig(
name="mistral",
api_base=custom_api_base,
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)
],
)
client = TelemetryClient(config_getter=lambda: config)
assert (
client._get_telemetry_url(custom_api_base)
== "https://api.custom.host/v1/datalake/events"
)
def test_telemetry_url_preserves_port_in_api_base(self) -> None:
from vibe.core.config import ProviderConfig
from vibe.core.types import Backend
custom_api_base = "http://api.custom.host:8080/v1/"
config = build_test_vibe_config(
enable_telemetry=True,
providers=[
ProviderConfig(
name="mistral",
api_base=custom_api_base,
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)
],
)
client = TelemetryClient(config_getter=lambda: config)
assert (
client._get_telemetry_url(custom_api_base)
== "http://api.custom.host:8080/v1/datalake/events"
)
def test_telemetry_url_falls_back_to_default_when_api_base_malformed(self) -> None:
from vibe.core.config import ProviderConfig
from vibe.core.types import Backend
config = build_test_vibe_config(
enable_telemetry=True,
providers=[
ProviderConfig(
name="mistral",
api_base="not-a-valid-url",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)
],
)
client = TelemetryClient(config_getter=lambda: config)
assert (
client._get_telemetry_url("not-a-valid-url")
== "https://codestral.mistral.ai/v1/datalake/events"
)
def test_is_active_false_when_mistral_provider_exists_but_no_api_key(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
config = build_test_vibe_config(enable_telemetry=True)
env_key = config.get_provider_for_model(
config.get_active_model()
).api_key_env_var
monkeypatch.delenv(env_key, raising=False)
client = TelemetryClient(config_getter=lambda: config)
assert client.is_active() is False
def test_is_active_false_when_no_mistral_provider(self) -> None:
from vibe.core.config import ProviderConfig
config = build_test_vibe_config(
enable_telemetry=True,
providers=[
ProviderConfig(
name="llamacpp",
api_base="http://127.0.0.1:8080/v1",
api_key_env_var="",
)
],
)
client = TelemetryClient(config_getter=lambda: config)
assert client.is_active() is False
def test_is_active_false_when_config_getter_raises_value_error(self) -> None:
def _raise_config_error() -> Any:
raise ValueError("config not ready")
client = TelemetryClient(config_getter=_raise_config_error)
assert client.is_active() is False

View File

@@ -129,7 +129,7 @@ class TestGitRepositoryGetInfo:
async def test_raises_when_not_git_repo(self, tmp_path: Path) -> None:
repo = GitRepository(tmp_path)
with pytest.raises(
ServiceTeleportNotSupportedError, match="Not a git repository"
ServiceTeleportNotSupportedError, match="Teleport requires a git repository"
):
await repo.get_info()

View File

@@ -185,7 +185,9 @@ class TestTeleportServiceCheckSupported:
self, service: TeleportService
) -> None:
service._git.get_info = AsyncMock(
side_effect=ServiceTeleportNotSupportedError("Not a git repository")
side_effect=ServiceTeleportNotSupportedError(
"Teleport requires a git repository. cd into a project with a .git directory and try again."
)
)
with pytest.raises(ServiceTeleportNotSupportedError):
await service.check_supported()

View File

@@ -10,8 +10,8 @@ import tomli_w
from vibe.core.paths import AGENTS_MD_FILENAME, TRUSTED_FOLDERS_FILE
from vibe.core.trusted_folders import (
TrustedFoldersManager,
find_trustable_files,
has_agents_md_file,
has_trustable_content,
)
@@ -319,34 +319,69 @@ class TestHasAgentsMdFile:
assert AGENTS_MD_FILENAME == "AGENTS.md"
class TestHasTrustableContent:
def test_returns_true_when_vibe_dir_exists(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
assert has_trustable_content(tmp_path) is True
class TestFindTrustableFiles:
def test_returns_empty_for_clean_directory(self, tmp_path: Path) -> None:
(tmp_path / "src").mkdir()
assert find_trustable_files(tmp_path) == []
def test_returns_true_when_agents_dir_exists(self, tmp_path: Path) -> None:
def test_detects_vibe_dir(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
result = find_trustable_files(tmp_path)
assert ".vibe/" in result
def test_detects_agents_dir(self, tmp_path: Path) -> None:
(tmp_path / ".agents" / "skills").mkdir(parents=True)
assert has_trustable_content(tmp_path) is True
result = find_trustable_files(tmp_path)
assert ".agents/" in result
def test_returns_true_when_agents_md_filename_exists(self, tmp_path: Path) -> None:
(tmp_path / AGENTS_MD_FILENAME).write_text("", encoding="utf-8")
assert has_trustable_content(tmp_path) is True
(tmp_path / AGENTS_MD_FILENAME).unlink()
def test_ignores_empty_vibe_dir(self, tmp_path: Path) -> None:
(tmp_path / ".vibe").mkdir()
assert find_trustable_files(tmp_path) == []
def test_returns_false_when_no_trustable_content(self, tmp_path: Path) -> None:
def test_ignores_empty_agents_dir(self, tmp_path: Path) -> None:
(tmp_path / ".agents").mkdir()
assert find_trustable_files(tmp_path) == []
def test_detects_agents_md(self, tmp_path: Path) -> None:
(tmp_path / "AGENTS.md").write_text("# Agent", encoding="utf-8")
result = find_trustable_files(tmp_path)
assert "AGENTS.md" in result
def test_returns_empty_when_no_trustable_content(self, tmp_path: Path) -> None:
(tmp_path / "other.txt").write_text("", encoding="utf-8")
assert has_trustable_content(tmp_path) is False
assert find_trustable_files(tmp_path) == []
def test_returns_true_when_vibe_config_in_subfolder(self, tmp_path: Path) -> None:
def test_detects_vibe_config_in_subfolder(self, tmp_path: Path) -> None:
(tmp_path / "sub" / ".vibe" / "skills").mkdir(parents=True)
assert has_trustable_content(tmp_path) is True
result = find_trustable_files(tmp_path)
assert "sub/.vibe/" in result
def test_returns_true_when_agents_skills_in_subfolder(self, tmp_path: Path) -> None:
def test_detects_agents_skills_in_subfolder(self, tmp_path: Path) -> None:
(tmp_path / "deep" / "nested" / ".agents" / "skills").mkdir(parents=True)
assert has_trustable_content(tmp_path) is True
result = find_trustable_files(tmp_path)
assert "deep/nested/.agents/" in result
def test_returns_false_when_config_only_inside_ignored_dir(
def test_returns_empty_when_config_only_inside_ignored_dir(
self, tmp_path: Path
) -> None:
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
assert has_trustable_content(tmp_path) is False
assert find_trustable_files(tmp_path) == []
def test_detects_nested_vibe_dir(self, tmp_path: Path) -> None:
(tmp_path / "pkg" / ".vibe" / "tools").mkdir(parents=True)
result = find_trustable_files(tmp_path)
assert "pkg/.vibe/" in result
def test_detects_multiple_files(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
(tmp_path / "AGENTS.md").write_text("# Agent", encoding="utf-8")
(tmp_path / "sub" / ".agents" / "skills").mkdir(parents=True)
result = find_trustable_files(tmp_path)
assert ".vibe/" in result
assert "AGENTS.md" in result
assert "sub/.agents/" in result
def test_no_duplicates_for_root_vibe_dir(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
result = find_trustable_files(tmp_path)
assert result.count(".vibe/") == 1

View File

@@ -5,7 +5,8 @@ from pathlib import Path
import pytest
from vibe.core.utils import get_server_url_from_api_base
from vibe.core.utils.io import read_safe
import vibe.core.utils.io as io_utils
from vibe.core.utils.io import decode_safe, read_safe, read_safe_async
@pytest.mark.parametrize(
@@ -26,39 +27,90 @@ class TestReadSafe:
def test_reads_utf8(self, tmp_path: Path) -> None:
f = tmp_path / "hello.txt"
f.write_text("café\n", encoding="utf-8")
assert read_safe(f) == "café\n"
assert read_safe(f).text == "café\n"
assert decode_safe(f.read_bytes()).text == "café\n"
def test_falls_back_on_non_utf8(self, tmp_path: Path) -> None:
def test_falls_back_on_non_utf8(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
f = tmp_path / "latin.txt"
# \x81 invalid UTF-8 and undefined in CP1252 → U+FFFD on all platforms
f.write_bytes(b"maf\x81\n")
monkeypatch.setattr(io_utils, "_encoding_from_best_match", lambda _raw: None)
result = read_safe(f)
assert result == "maf<EFBFBD>\n"
assert result.text == "maf<EFBFBD>\n"
def test_falls_back_to_detected_encoding(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
f = tmp_path / "utf16.txt"
expected = "hello été\n"
f.write_bytes(expected.encode("utf-16le"))
monkeypatch.setattr(
io_utils.locale, "getpreferredencoding", lambda _do_setlocale: "utf-8"
)
assert read_safe(f).text == expected
def test_raise_on_error_true_utf8_succeeds(self, tmp_path: Path) -> None:
f = tmp_path / "hello.txt"
f.write_text("café\n", encoding="utf-8")
assert read_safe(f, raise_on_error=True) == "café\n"
assert read_safe(f, raise_on_error=True).text == "café\n"
def test_raise_on_error_true_non_utf8_raises(self, tmp_path: Path) -> None:
def test_raise_on_error_true_non_utf8_raises(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
f = tmp_path / "bad.txt"
# Invalid UTF-8; with raise_on_error=True we use default encoding (strict), so decode errors propagate
f.write_bytes(b"maf\x81\n")
assert read_safe(f, raise_on_error=False) == "maf<EFBFBD>\n"
monkeypatch.setattr(io_utils, "_encoding_from_best_match", lambda _raw: None)
assert read_safe(f, raise_on_error=False).text == "maf<EFBFBD>\n"
with pytest.raises(UnicodeDecodeError):
read_safe(f, raise_on_error=True)
def test_empty_file(self, tmp_path: Path) -> None:
f = tmp_path / "empty.txt"
f.write_bytes(b"")
assert read_safe(f) == ""
assert read_safe(f).text == ""
def test_binary_garbage_does_not_raise(self, tmp_path: Path) -> None:
f = tmp_path / "garbage.bin"
f.write_bytes(bytes(range(256)))
result = read_safe(f)
assert isinstance(result, str)
assert isinstance(result.text, str)
def test_file_not_found_raises(self, tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError):
read_safe(tmp_path / "nope.txt")
class TestReadSafeResultEncoding:
def test_reports_utf8_for_plain_utf8_file(self, tmp_path: Path) -> None:
f = tmp_path / "x.txt"
f.write_text("ok\n", encoding="utf-8")
got = read_safe(f)
assert got.text == "ok\n"
assert got.encoding == "utf-8"
@pytest.mark.asyncio
async def test_async_reports_utf16_when_bom_present(self, tmp_path: Path) -> None:
f = tmp_path / "u16.txt"
f.write_bytes("a\n".encode("utf-16"))
got = await read_safe_async(f)
assert got.encoding == "utf-16-le"
# utf-16-le leaves the BOM as U+FEFF in the string (unlike utf-8-sig).
assert got.text == "\ufeffa\n"
class TestReadSafeAsync:
@pytest.mark.asyncio
async def test_raise_on_error_final_utf8_strict_or_replace(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""raise_on_error controls strict vs replace on the last UTF-8 fallback."""
f = tmp_path / "bad.txt"
f.write_bytes(b"maf\x81\n")
monkeypatch.setattr(io_utils, "_encoding_from_best_match", lambda _raw: None)
assert (await read_safe_async(f, raise_on_error=False)).text == "maf<EFBFBD>\n"
with pytest.raises(UnicodeDecodeError):
await read_safe_async(f, raise_on_error=True)

View File

@@ -5,12 +5,14 @@ from pathlib import Path
import pytest
from tests.mock.utils import collect_result
from vibe.core.config.harness_files import (
init_harness_files_manager,
reset_harness_files_manager,
)
from vibe.core.tools.builtins.read_file import (
ReadFile,
ReadFileArgs,
ReadFileResult,
ReadFileState,
ReadFileToolConfig,
@@ -37,6 +39,30 @@ def _make_read_file() -> ReadFile:
return ReadFile(config_getter=lambda: ReadFileToolConfig(), state=ReadFileState())
class TestReadFileExecution:
@pytest.mark.asyncio
async def test_run_with_large_offset_still_reads_lines(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
test_file = tmp_path / "large_file.txt"
test_file.write_text(
"".join(f"line {i}\n" for i in range(200)), encoding="utf-8"
)
tool = ReadFile(
config_getter=lambda: ReadFileToolConfig(max_read_bytes=64),
state=ReadFileState(),
)
result = await collect_result(
tool.run(ReadFileArgs(path=str(test_file), offset=50, limit=2))
)
assert result.content == "line 50\nline 51\n"
assert result.lines_read == 2
assert not result.was_truncated
class TestGetResultExtra:
@pytest.mark.usefixtures("_setup_manager")
def test_returns_none_when_no_agents_md(self, tmp_path: Path) -> None:

View File

View File

@@ -0,0 +1,192 @@
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock
import pytest
from tests.conftest import build_test_vibe_config
from tests.stubs.fake_audio_player import FakeAudioPlayer
from tests.stubs.fake_tts_client import FakeTTSClient
from vibe.cli.narrator_manager import NarratorManager, NarratorState
from vibe.cli.turn_summary import TurnSummaryResult
from vibe.core.tts.tts_client_port import TTSResult
def _make_manager(
*,
narrator_enabled: bool = True,
telemetry_client: MagicMock | None = None,
tts_client: FakeTTSClient | None = None,
) -> tuple[NarratorManager, FakeAudioPlayer]:
config = build_test_vibe_config(narrator_enabled=narrator_enabled)
audio_player = FakeAudioPlayer()
manager = NarratorManager(
config_getter=lambda: config,
audio_player=audio_player,
telemetry_client=telemetry_client,
)
manager._tts_client = tts_client or FakeTTSClient(
result=TTSResult(audio_data=b"fake-audio")
)
return manager, audio_player
def _find_telemetry_calls(mock: MagicMock, event_name: str) -> list[dict[str, object]]:
results: list[dict[str, object]] = []
for call in mock.send_telemetry_event.call_args_list:
if call[0][0] == event_name:
results.append(call[0][1])
return results
class TestTelemetryTracking:
@pytest.mark.asyncio
async def test_requested_event_on_turn_end(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._turn_summary.start_turn("test")
manager.on_turn_end()
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.requested")
assert len(calls) == 1
assert calls[0]["trigger"] == "autoplay_next_message"
assert isinstance(calls[0]["read_aloud_session_id"], str)
assert len(calls[0]["read_aloud_session_id"]) == 36
def test_no_requested_event_when_narrator_disabled(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(
narrator_enabled=False, telemetry_client=mock_telemetry
)
manager.on_turn_end()
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.requested")
assert len(calls) == 0
@pytest.mark.asyncio
async def test_play_started_on_speak(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._turn_summary.start_turn("test")
manager.on_turn_end()
manager._on_turn_summary(
TurnSummaryResult(
summary="Test summary", generation=manager._turn_summary.generation
)
)
await asyncio.sleep(0)
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.play_started")
assert len(calls) == 1
assert calls[0]["speed_selection"] is None
assert isinstance(calls[0]["time_to_first_read_s"], float)
assert calls[0]["time_to_first_read_s"] >= 0.0
@pytest.mark.asyncio
async def test_ended_completed_on_playback_finished(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._turn_summary.start_turn("test")
manager.on_turn_end()
manager._on_turn_summary(
TurnSummaryResult(
summary="Test summary", generation=manager._turn_summary.generation
)
)
await asyncio.sleep(0)
assert manager.state == NarratorState.SPEAKING
manager._on_playback_finished()
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
assert len(calls) == 1
assert calls[0]["status"] == "completed"
assert calls[0]["error_type"] is None
assert calls[0]["speed_selection"] is None
assert isinstance(calls[0]["elapsed_seconds"], float)
assert calls[0]["elapsed_seconds"] >= 0.0
@pytest.mark.asyncio
async def test_ended_error_on_tts_failure(self) -> None:
mock_telemetry = MagicMock()
class FailingTTSClient:
def __init__(self, *_args: object, **_kwargs: object) -> None:
pass
async def speak(self, text: str) -> TTSResult:
raise RuntimeError("TTS failed")
async def close(self) -> None:
pass
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._tts_client = FailingTTSClient()
manager._turn_summary.start_turn("test")
manager.on_turn_end()
manager._on_turn_summary(
TurnSummaryResult(
summary="Test summary", generation=manager._turn_summary.generation
)
)
await asyncio.sleep(0)
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
assert len(calls) == 1
assert calls[0]["status"] == "error"
assert calls[0]["error_type"] == "RuntimeError"
@pytest.mark.asyncio
async def test_ended_canceled_on_cancel(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._turn_summary.start_turn("test")
manager.on_turn_end()
assert manager.state == NarratorState.SUMMARIZING
manager.cancel()
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
assert len(calls) == 1
assert calls[0]["status"] == "canceled"
@pytest.mark.asyncio
async def test_cancel_during_speaking_fires_single_ended_event(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._turn_summary.start_turn("test")
manager.on_turn_end()
manager._on_turn_summary(
TurnSummaryResult(
summary="Test summary", generation=manager._turn_summary.generation
)
)
await asyncio.sleep(0)
assert manager.state == NarratorState.SPEAKING
manager.cancel()
# Simulate the delayed callback that would come from call_soon_threadsafe
manager._on_playback_finished()
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
assert len(calls) == 1
assert calls[0]["status"] == "canceled"
@pytest.mark.asyncio
async def test_no_error_without_telemetry_client(self) -> None:
manager, _ = _make_manager()
manager._turn_summary.start_turn("test")
manager.on_turn_end()
manager._on_turn_summary(
TurnSummaryResult(
summary="Test summary", generation=manager._turn_summary.generation
)
)
await asyncio.sleep(0)
manager._on_playback_finished()

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
from vibe.cli.narrator_manager.telemetry import ReadAloudTrackingState
class TestReadAloudTrackingState:
def test_default_state(self) -> None:
state = ReadAloudTrackingState()
assert state.session_id == ""
assert state.request_time == 0.0
assert state.play_start_time == 0.0
def test_reset_generates_session_id(self) -> None:
state = ReadAloudTrackingState()
state.reset()
assert state.session_id != ""
assert len(state.session_id) == 36 # UUID format
def test_reset_generates_unique_session_ids(self) -> None:
state = ReadAloudTrackingState()
state.reset()
first_id = state.session_id
state.reset()
assert state.session_id != first_id
def test_reset_clears_play_start_time(self) -> None:
state = ReadAloudTrackingState()
state.reset()
state.mark_play_started()
assert state.play_start_time > 0.0
state.reset()
assert state.play_start_time == 0.0
def test_mark_play_started(self) -> None:
state = ReadAloudTrackingState()
state.reset()
state.mark_play_started()
assert state.play_start_time > 0.0
def test_time_to_first_read_s(self) -> None:
state = ReadAloudTrackingState()
state.reset()
state.mark_play_started()
ttfr = state.time_to_first_read_s()
assert ttfr >= 0.0
def test_time_to_first_read_s_before_play(self) -> None:
state = ReadAloudTrackingState()
state.reset()
assert state.time_to_first_read_s() == 0.0
def test_elapsed_since_play_s(self) -> None:
state = ReadAloudTrackingState()
state.reset()
state.mark_play_started()
elapsed = state.elapsed_since_play_s()
assert elapsed >= 0.0
def test_elapsed_since_play_s_before_play(self) -> None:
state = ReadAloudTrackingState()
assert state.elapsed_since_play_s() == 0.0

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
from pathlib import Path
import sys
from typing import override
@@ -25,7 +24,7 @@ def _exit_raiser(code: int = 0) -> None:
def test_exits_on_cancel(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr(sys, "exit", _exit_raiser)
@@ -38,7 +37,7 @@ def test_exits_on_cancel(
def test_warns_on_save_error(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr(sys, "exit", _exit_raiser)
@@ -49,8 +48,25 @@ def test_warns_on_save_error(
assert "disk full" in out
def test_exits_on_invalid_api_key_env_var(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr(sys, "exit", _exit_raiser)
with pytest.raises(SystemExit) as excinfo:
onboarding.run_onboarding(StubApp("env_var_error:BAD=NAME"))
assert excinfo.value.code == 1
out = capsys.readouterr().out
assert "Could not save the API key because this provider is configured" in out
assert "invalid" in out
assert "environment variable name: BAD=NAME" in out
assert "was not saved for this session" in out
assert "set for this session only" not in out
def test_successfully_completes(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr(sys, "exit", _exit_raiser)

View File

@@ -1,14 +1,16 @@
from __future__ import annotations
from collections.abc import Callable
from types import SimpleNamespace
import pytest
from textual.pilot import Pilot
from textual.widgets import Input
from vibe.core.config import ProviderConfig
from vibe.core.paths import GLOBAL_ENV_FILE
from vibe.setup.onboarding import OnboardingApp
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen, persist_api_key
async def _wait_for(
@@ -55,3 +57,64 @@ async def test_ui_gets_through_the_onboarding_successfully() -> None:
env_contents = GLOBAL_ENV_FILE.path.read_text(encoding="utf-8")
assert "MISTRAL_API_KEY" in env_contents
assert api_key_value in env_contents
def test_api_key_screen_falls_back_to_mistral_for_provider_without_env_key() -> None:
screen = ApiKeyScreen(
provider=ProviderConfig(
name="llamacpp", api_base="http://127.0.0.1:8080/v1", api_key_env_var=""
)
)
assert screen.provider.name == "mistral"
assert screen.provider.api_key_env_var == "MISTRAL_API_KEY"
def test_api_key_screen_keeps_provider_with_explicit_env_key() -> None:
provider = ProviderConfig(
name="custom",
api_base="https://custom.example/v1",
api_key_env_var="CUSTOM_API_KEY",
)
screen = ApiKeyScreen(provider=provider)
assert screen.provider == provider
def test_api_key_screen_uses_mistral_fallback_for_context_without_env_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"vibe.setup.onboarding.screens.api_key.OnboardingContext.load",
lambda: SimpleNamespace(
provider=ProviderConfig(
name="llamacpp", api_base="http://127.0.0.1:8080/v1", api_key_env_var=""
)
),
)
screen = ApiKeyScreen()
assert screen.provider.name == "mistral"
assert screen.provider.api_key_env_var == "MISTRAL_API_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"
)
result = persist_api_key(provider, "secret")
assert result == "env_var_error:BAD=NAME"
def test_persist_api_key_returns_env_var_error_for_empty_env_var_name() -> None:
provider = ProviderConfig(
name="custom", api_base="https://custom.example/v1", api_key_env_var=""
)
result = persist_api_key(provider, "secret")
assert result == "env_var_error:<empty>"

View File

@@ -9,7 +9,8 @@ import pytest
from vibe.core.config import SessionLoggingConfig
from vibe.core.session.session_loader import SessionLoader
from vibe.core.types import LLMMessage, Role, ToolCall
from vibe.core.types import LLMMessage, Role, SessionMetadata, ToolCall
from vibe.core.utils.io import read_safe
@pytest.fixture
@@ -1008,7 +1009,7 @@ class TestSessionLoaderUTF8Encoding:
session_folder = session_dir / "test_20230101_120000_latin100"
session_folder.mkdir()
# \x81 invalid UTF-8 and undefined in CP1252 → U+FFFD on all platforms
# Path contains U+0081; file written as Latin-1. Decoding matches read_safe.
metadata_content = {
"session_id": "latin1-test",
"start_time": "2023-01-01T12:00:00Z",
@@ -1026,9 +1027,10 @@ class TestSessionLoaderUTF8Encoding:
messages_file = session_folder / "messages.jsonl"
messages_file.write_text('{"role": "user", "content": "Hello"}\n')
expected = SessionMetadata.model_validate_json(read_safe(metadata_file).text)
metadata = SessionLoader.load_metadata(session_folder)
assert metadata.session_id == "latin1-test"
assert metadata.environment["working_directory"] == "/home/user/caf<61>_project"
assert metadata == expected
def test_load_session_with_utf8_metadata_and_messages(
self, session_config: SessionLoggingConfig

View File

@@ -78,9 +78,9 @@
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#121212" x="0" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="1.5" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="573.4" y="1.5" width="402.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<rect fill="#121212" x="0" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="1.5" width="805.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="829.6" y="1.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"></text><text class="terminal-r3" x="24.4" y="20" textLength="549" clip-path="url(#terminal-line-0)">Teleported&#160;to&#160;Nuage:&#160;https://chat.example.com</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
<text class="terminal-r1" x="0" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"></text><text class="terminal-r3" x="24.4" y="20" textLength="805.2" clip-path="url(#terminal-line-0)">Teleported&#160;to&#160;a&#160;new&#160;async&#160;coding&#160;session:&#160;https://chat.example.com</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -180,9 +180,9 @@
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="483.6" textLength="183" clip-path="url(#terminal-line-19)">devstral-latest</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="414.8" clip-path="url(#terminal-line-20)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type&#160;</text><text class="terminal-r3" x="231.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">/help</text><text class="terminal-r1" x="292.8" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="61" clip-path="url(#terminal-line-20)">Type&#160;</text><text class="terminal-r3" x="231.8" y="508" textLength="61" clip-path="url(#terminal-line-20)">/help</text><text class="terminal-r1" x="292.8" y="508" textLength="256.2" clip-path="url(#terminal-line-20)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r5" x="24.4" y="581.2" textLength="122" clip-path="url(#terminal-line-23)">What&#x27;s&#160;New</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="24.4" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">&#160;Feature&#160;1</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

138
tests/test_deferred_init.py Normal file
View File

@@ -0,0 +1,138 @@
"""Tests for deferred initialization: _complete_init, wait_for_init, integrate_mcp idempotency."""
from __future__ import annotations
import threading
from unittest.mock import MagicMock, patch
import pytest
from tests.conftest import build_test_agent_loop, build_test_vibe_config
from tests.stubs.fake_mcp_registry import FakeMCPRegistry
from vibe.core.config import MCPStdio
from vibe.core.tools.manager import ToolManager
# ---------------------------------------------------------------------------
# _complete_init
# ---------------------------------------------------------------------------
class TestCompleteInit:
def test_success_sets_init_complete(self) -> None:
loop = build_test_agent_loop(defer_heavy_init=True)
assert not loop.is_initialized
loop._complete_init()
assert loop.is_initialized
assert loop._init_error is None
def test_failure_sets_init_complete_and_stores_error(self) -> None:
loop = build_test_agent_loop(defer_heavy_init=True)
error = RuntimeError("mcp boom")
with patch.object(loop.tool_manager, "integrate_mcp", side_effect=error):
loop._complete_init()
assert loop.is_initialized
assert loop._init_error is error
def test_mcp_integration_internal_failure_sets_init_error(self) -> None:
mcp_server = MCPStdio(name="test-server", transport="stdio", command="echo")
config = build_test_vibe_config(mcp_servers=[mcp_server])
loop = build_test_agent_loop(config=config, defer_heavy_init=True)
with patch.object(
loop.tool_manager._mcp_registry,
"get_tools",
side_effect=RuntimeError("mcp discovery boom"),
):
loop._complete_init()
assert loop.is_initialized
assert isinstance(loop._init_error, RuntimeError)
assert str(loop._init_error) == "mcp discovery boom"
# ---------------------------------------------------------------------------
# wait_for_init
# ---------------------------------------------------------------------------
class TestWaitForInit:
@pytest.mark.asyncio
async def test_returns_immediately_when_already_complete(self) -> None:
loop = build_test_agent_loop(defer_heavy_init=True)
loop._complete_init()
await loop.wait_for_init() # should not block
@pytest.mark.asyncio
async def test_waits_for_background_thread(self) -> None:
loop = build_test_agent_loop(defer_heavy_init=True)
thread = threading.Thread(target=loop._complete_init, daemon=True)
thread.start()
await loop.wait_for_init()
thread.join(timeout=1)
assert loop.is_initialized
@pytest.mark.asyncio
async def test_raises_stored_error(self) -> None:
loop = build_test_agent_loop(defer_heavy_init=True)
error = RuntimeError("init failed")
with patch.object(loop.tool_manager, "integrate_mcp", side_effect=error):
loop._complete_init()
with pytest.raises(RuntimeError, match="init failed"):
await loop.wait_for_init()
@pytest.mark.asyncio
async def test_raises_error_for_every_caller(self) -> None:
loop = build_test_agent_loop(defer_heavy_init=True)
error = RuntimeError("once only")
with patch.object(loop.tool_manager, "integrate_mcp", side_effect=error):
loop._complete_init()
with pytest.raises(RuntimeError):
await loop.wait_for_init()
with pytest.raises(RuntimeError):
await loop.wait_for_init()
# ---------------------------------------------------------------------------
# integrate_mcp idempotency
# ---------------------------------------------------------------------------
class TestIntegrateMcpIdempotency:
def test_second_call_is_noop(self) -> None:
mcp_server = MCPStdio(name="test-server", transport="stdio", command="echo")
config = build_test_vibe_config(mcp_servers=[mcp_server])
registry = FakeMCPRegistry()
manager = ToolManager(lambda: config, mcp_registry=registry, defer_mcp=True)
manager.integrate_mcp()
tools_after_first = dict(manager.registered_tools)
# Spy on the registry to ensure get_tools is not called again.
registry.get_tools = MagicMock(wraps=registry.get_tools)
manager.integrate_mcp()
registry.get_tools.assert_not_called()
assert manager.registered_tools == tools_after_first
def test_flag_not_set_when_no_servers(self) -> None:
config = build_test_vibe_config(mcp_servers=[])
manager = ToolManager(lambda: config, defer_mcp=True)
manager.integrate_mcp()
# No servers means the method returns early without setting the flag,
# so a future call with servers would still run discovery.
assert not manager._mcp_integrated

View File

@@ -1,8 +1,8 @@
from __future__ import annotations
import base64
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from vibe.core.config import TTSModelConfig, TTSProviderConfig
@@ -27,13 +27,20 @@ def _make_model() -> TTSModelConfig:
class TestMistralTTSClientInit:
def test_client_configured_with_base_url_and_auth(
def test_lazy_client_creation(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
client = MistralTTSClient(_make_provider(), _make_model())
assert client._client is None
def test_get_client_creates_mistral_instance(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
client = MistralTTSClient(_make_provider(), _make_model())
assert str(client._client.base_url) == "https://api.mistral.ai/v1/"
assert client._client.headers["authorization"] == "Bearer test-key"
sdk_client = client._get_client()
assert sdk_client is not None
assert client._client is sdk_client
assert client._get_client() is sdk_client
class TestMistralTTSClient:
@@ -46,55 +53,62 @@ class TestMistralTTSClient:
raw_audio = b"fake-audio-data-for-testing"
encoded_audio = base64.b64encode(raw_audio).decode()
async def mock_post(self_client, url, **kwargs):
assert url == "/audio/speech"
body = kwargs["json"]
assert body["model"] == "voxtral-mini-tts-latest"
assert body["input"] == "Hello"
assert body["voice_id"] == "gb_jane_neutral"
assert body["stream"] is False
assert body["response_format"] == "wav"
return httpx.Response(
status_code=200,
json={"audio_data": encoded_audio},
request=httpx.Request("POST", url),
)
mock_response = MagicMock()
mock_response.audio_data = encoded_audio
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
mock_complete_async = AsyncMock(return_value=mock_response)
client = MistralTTSClient(_make_provider(), _make_model())
result = await client.speak("Hello")
with patch.object(
type(client._get_client().audio.speech),
"complete_async",
mock_complete_async,
):
result = await client.speak("Hello")
assert isinstance(result, TTSResult)
assert result.audio_data == raw_audio
await client.close()
mock_complete_async.assert_called_once_with(
model="voxtral-mini-tts-latest",
input="Hello",
voice_id="gb_jane_neutral",
response_format="wav",
)
@pytest.mark.asyncio
async def test_speak_raises_on_http_error(
async def test_speak_raises_on_sdk_error(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
import httpx
from mistralai.client.errors import SDKError
async def mock_post(self_client, url, **kwargs):
return httpx.Response(
status_code=500,
json={"error": "Internal Server Error"},
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
fake_response = httpx.Response(
status_code=500,
request=httpx.Request("POST", "https://api.mistral.ai/v1/audio/speech"),
)
mock_complete_async = AsyncMock(
side_effect=SDKError("API error", fake_response)
)
client = MistralTTSClient(_make_provider(), _make_model())
with pytest.raises(httpx.HTTPStatusError):
with (
patch.object(
type(client._get_client().audio.speech),
"complete_async",
mock_complete_async,
),
pytest.raises(SDKError),
):
await client.speak("Hello")
await client.close()
@pytest.mark.asyncio
async def test_close_closes_underlying_client(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
async def test_close_resets_client(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
client = MistralTTSClient(_make_provider(), _make_model())
client._get_client()
assert client._client is not None
await client.close()
assert client._client.is_closed
assert client._client is None

View File

@@ -122,7 +122,7 @@ def test_load_whats_new_content_handles_os_error(tmp_path: Path) -> None:
whats_new_file.write_text("content")
with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
with patch.object(Path, "read_text", side_effect=OSError("Permission denied")):
with patch.object(Path, "read_bytes", side_effect=OSError("Permission denied")):
result = load_whats_new_content()
assert result is None

37
uv.lock generated
View File

@@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.12"
[options]
exclude-newer = "2026-04-02T14:58:56.644057Z"
exclude-newer = "2026-04-06T21:12:11.186502Z"
exclude-newer-span = "P7D"
[options.exclude-newer-package]
@@ -569,6 +569,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "jsonpatch"
version = "1.33"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpointer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" },
]
[[package]]
name = "jsonpath-python"
version = "1.1.5"
@@ -578,6 +590,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/28/50/1a313fb700526b134c71eb8a225d8b83be0385dbb0204337b4379c698cef/jsonpath_python-1.1.5-py3-none-any.whl", hash = "sha256:a60315404d70a65e76c9a782c84e50600480221d94a58af47b7b4d437351cb4b", size = 14090, upload-time = "2026-03-17T06:16:39.152Z" },
]
[[package]]
name = "jsonpointer"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
@@ -787,17 +808,19 @@ wheels = [
[[package]]
name = "mistral-vibe"
version = "2.7.4"
version = "2.7.5"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },
{ name = "anyio" },
{ name = "cachetools" },
{ name = "charset-normalizer" },
{ name = "cryptography" },
{ name = "gitpython" },
{ name = "giturlparse" },
{ name = "google-auth" },
{ name = "httpx" },
{ name = "jsonpatch" },
{ name = "keyring" },
{ name = "markdownify" },
{ name = "mcp" },
@@ -853,11 +876,13 @@ requires-dist = [
{ name = "agent-client-protocol", specifier = "==0.9.0" },
{ name = "anyio", specifier = ">=4.12.0" },
{ name = "cachetools", specifier = ">=5.5.0" },
{ name = "charset-normalizer", specifier = ">=3.4.4" },
{ name = "cryptography", specifier = ">=44.0.0,<=46.0.3" },
{ name = "gitpython", specifier = ">=3.1.46" },
{ name = "giturlparse", specifier = ">=0.14.0" },
{ name = "google-auth", specifier = ">=2.0.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "jsonpatch", specifier = ">=1.33" },
{ name = "keyring", specifier = ">=25.6.0" },
{ name = "markdownify", specifier = ">=1.2.2" },
{ name = "mcp", specifier = ">=1.14.0" },
@@ -876,7 +901,7 @@ requires-dist = [
{ name = "requests", specifier = ">=2.20.0" },
{ name = "rich", specifier = ">=14.0.0" },
{ name = "sounddevice", specifier = ">=0.5.1" },
{ name = "textual", specifier = ">=8.1.1" },
{ name = "textual", specifier = ">=8.2.1" },
{ name = "textual-speedups", specifier = ">=0.2.1" },
{ name = "tomli-w", specifier = ">=1.2.0" },
{ name = "tree-sitter", specifier = ">=0.25.2" },
@@ -1901,7 +1926,7 @@ wheels = [
[[package]]
name = "textual"
version = "8.1.1"
version = "8.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
@@ -1911,9 +1936,9 @@ dependencies = [
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/23/8c709655c5f2208ee82ab81b8104802421865535c278a7649b842b129db1/textual-8.1.1.tar.gz", hash = "sha256:eef0256a6131f06a20ad7576412138c1f30f92ddeedd055953c08d97044bc317", size = 1843002, upload-time = "2026-03-10T10:01:38.493Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4f/07/766ad19cf2b15cae2d79e0db46a1b783b62316e9ff3e058e7424b2a4398b/textual-8.2.1.tar.gz", hash = "sha256:4176890e9cd5c95dcdd206541b2956b0808e74c8c36381c88db53dcb45237451", size = 1848386, upload-time = "2026-03-29T03:57:32.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/21/421b02bf5943172b7a9320712a5e0d74a02a8f7597284e3f8b5b06c70b8d/textual-8.1.1-py3-none-any.whl", hash = "sha256:6712f96e335cd782e76193dee16b9c8875fe0699d923bc8d3f1228fd23e773a6", size = 719598, upload-time = "2026-03-10T10:01:48.318Z" },
{ url = "https://files.pythonhosted.org/packages/25/09/c6f000c2e3702036e593803319af02feee58a662528d0d5728a37e1cf81b/textual-8.2.1-py3-none-any.whl", hash = "sha256:746cbf947a8ca875afc09779ef38cadbc7b9f15ac886a5090f7099fef5ade990", size = 723871, upload-time = "2026-03-29T03:57:34.334Z" },
]
[[package]]

View File

@@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
__version__ = "2.7.4"
__version__ = "2.7.5"

View File

@@ -19,10 +19,12 @@ from vibe.core.tools.builtins.search_replace import (
SearchReplaceResult,
)
from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.utils.io import ReadSafeResult
class AcpSearchReplaceState(BaseToolState, AcpToolState):
file_backup_content: str | None = None
file_backup_encoding: str = "utf-8"
class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
@@ -35,7 +37,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
def _get_tool_state_class(cls) -> type[AcpSearchReplaceState]:
return AcpSearchReplaceState
async def _read_file(self, file_path: Path) -> str:
async def _read_file(self, file_path: Path) -> ReadSafeResult:
client, session_id, _ = self._load_state()
await self._send_in_progress_session_update()
@@ -48,7 +50,8 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
raise ToolError(f"Unexpected error reading {file_path}: {e}") from e
self.state.file_backup_content = response.content
return response.content
self.state.file_backup_encoding = "utf-8"
return ReadSafeResult(response.content, "utf-8")
async def _backup_file(self, file_path: Path) -> None:
if self.state.file_backup_content is None:
@@ -57,9 +60,10 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
await self._write_file(
file_path.with_suffix(file_path.suffix + ".bak"),
self.state.file_backup_content,
self.state.file_backup_encoding,
)
async def _write_file(self, file_path: Path, content: str) -> None:
async def _write_file(self, file_path: Path, content: str, encoding: str) -> None:
client, session_id, _ = self._load_state()
try:

View File

@@ -207,11 +207,14 @@ def run_cli(args: argparse.Namespace) -> None:
client_name="vibe_cli",
client_version=__version__,
),
defer_heavy_init=True,
)
if loaded_session:
_resume_previous_session(agent_loop, *loaded_session)
agent_loop.start_deferred_init()
run_textual_ui(
agent_loop=agent_loop,
startup=StartupOptions(

View File

@@ -10,7 +10,7 @@ from rich import print as rprint
from vibe import __version__
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config.harness_files import init_harness_files_manager
from vibe.core.trusted_folders import has_trustable_content, trusted_folders_manager
from vibe.core.trusted_folders import find_trustable_files, trusted_folders_manager
from vibe.setup.trusted_folders.trust_folder_dialog import (
TrustDialogQuitException,
ask_trust_folder,
@@ -118,7 +118,12 @@ def check_and_resolve_trusted_folder() -> None:
)
sys.exit(1)
if not has_trustable_content(cwd) or cwd.resolve() == Path.home().resolve():
if cwd.resolve() == Path.home().resolve():
return
detected_files = find_trustable_files(cwd)
if not detected_files:
return
is_folder_trusted = trusted_folders_manager.is_trusted(cwd)
@@ -127,7 +132,7 @@ def check_and_resolve_trusted_folder() -> None:
return
try:
is_folder_trusted = ask_trust_folder(cwd)
is_folder_trusted = ask_trust_folder(cwd, detected_files)
except (KeyboardInterrupt, EOFError, TrustDialogQuitException):
sys.exit(0)
except Exception as e:

View File

@@ -3,6 +3,8 @@ from __future__ import annotations
import json
from pathlib import Path
from vibe.core.utils.io import read_safe
class HistoryManager:
def __init__(self, history_file: Path, max_entries: int = 100) -> None:
@@ -18,20 +20,22 @@ class HistoryManager:
return
try:
with self.history_file.open("r", encoding="utf-8") as f:
entries = []
for raw_line in f:
raw_line = raw_line.rstrip("\n\r")
if not raw_line:
continue
try:
entry = json.loads(raw_line)
except json.JSONDecodeError:
entry = raw_line
entries.append(entry if isinstance(entry, str) else str(entry))
self._entries = entries[-self.max_entries :]
except (OSError, UnicodeDecodeError):
text = read_safe(self.history_file).text
except OSError:
self._entries = []
return
entries = []
for raw_line in text.splitlines():
raw_line = raw_line.rstrip("\n\r")
if not raw_line:
continue
try:
entry = json.loads(raw_line)
except json.JSONDecodeError:
entry = raw_line
entries.append(entry if isinstance(entry, str) else str(entry))
self._entries = entries[-self.max_entries :]
def _save_history(self) -> None:
try:

View File

@@ -7,6 +7,7 @@ from vibe.cli.narrator_manager.narrator_manager_port import (
NarratorManagerListener,
NarratorState,
)
from vibe.cli.narrator_manager.telemetry import ReadAloudTrackingState
from vibe.cli.turn_summary import (
NoopTurnSummary,
TurnSummaryResult,
@@ -24,16 +25,21 @@ if TYPE_CHECKING:
from vibe.cli.turn_summary import TurnSummaryPort
from vibe.core.audio_player.audio_player_port import AudioPlayerPort
from vibe.core.config import VibeConfig
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.tts.tts_client_port import TTSClientPort
from vibe.core.types import BaseEvent
class NarratorManager:
def __init__(
self, config_getter: Callable[[], VibeConfig], audio_player: AudioPlayerPort
self,
config_getter: Callable[[], VibeConfig],
audio_player: AudioPlayerPort,
telemetry_client: TelemetryClient | None = None,
) -> None:
self._config_getter = config_getter
self._audio_player = audio_player
self._telemetry_client = telemetry_client
config = config_getter()
self._turn_summary: TurnSummaryPort = self._make_turn_summary(config)
self._turn_summary.on_summary = self._on_turn_summary
@@ -43,6 +49,7 @@ class NarratorManager:
self._cancel_summary: Callable[[], bool] | None = None
self._close_tasks: set[asyncio.Task[Any]] = set()
self._listeners: list[NarratorManagerListener] = []
self._tracking = ReadAloudTrackingState()
@property
def state(self) -> NarratorState:
@@ -99,8 +106,12 @@ class NarratorManager:
):
self._cancel_summary = cancel_summary
self._set_state(NarratorState.SUMMARIZING)
self._tracking.reset()
self._on_read_aloud_requested()
def cancel(self) -> None:
if self._state != NarratorState.IDLE:
self._on_read_aloud_ended("canceled")
if self._cancel_summary is not None:
self._cancel_summary()
self._cancel_summary = None
@@ -178,17 +189,65 @@ class NarratorManager:
loop = asyncio.get_running_loop()
tts_result = await self._tts_client.speak(text)
self._set_state(NarratorState.SPEAKING)
self._tracking.mark_play_started()
self._on_read_aloud_play_started()
self._audio_player.play(
tts_result.audio_data,
AudioFormat.WAV,
on_finished=lambda: loop.call_soon_threadsafe(
self._set_state, NarratorState.IDLE
self._on_playback_finished
),
)
except Exception:
except Exception as exc:
logger.warning("TTS speak failed", exc_info=True)
self._on_read_aloud_ended("error", error_type=type(exc).__name__)
self._set_state(NarratorState.IDLE)
def _on_playback_finished(self) -> None:
if self._state != NarratorState.SPEAKING:
return
self._on_read_aloud_ended("completed")
self._set_state(NarratorState.IDLE)
def _on_read_aloud_requested(self) -> None:
if not self._telemetry_client:
return
self._telemetry_client.send_telemetry_event(
"vibe.read_aloud.requested",
{
"read_aloud_session_id": self._tracking.session_id,
"trigger": "autoplay_next_message",
},
)
def _on_read_aloud_play_started(self) -> None:
if not self._telemetry_client:
return
self._telemetry_client.send_telemetry_event(
"vibe.read_aloud.play_started",
{
"read_aloud_session_id": self._tracking.session_id,
"time_to_first_read_s": self._tracking.time_to_first_read_s(),
"speed_selection": None,
},
)
def _on_read_aloud_ended(
self, status: str, *, error_type: str | None = None
) -> None:
if not self._telemetry_client:
return
self._telemetry_client.send_telemetry_event(
"vibe.read_aloud.ended",
{
"read_aloud_session_id": self._tracking.session_id,
"status": status,
"error_type": error_type,
"speed_selection": None,
"elapsed_seconds": self._tracking.elapsed_since_play_s(),
},
)
def _set_state(self, state: NarratorState) -> None:
self._state = state
for listener in list(self._listeners):

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from dataclasses import dataclass
import time
import uuid
@dataclass
class ReadAloudTrackingState:
session_id: str = ""
request_time: float = 0.0
play_start_time: float = 0.0
def reset(self) -> None:
self.session_id = str(uuid.uuid4())
self.request_time = time.monotonic()
self.play_start_time = 0.0
def mark_play_started(self) -> None:
self.play_start_time = time.monotonic()
def time_to_first_read_s(self) -> float:
if self.play_start_time == 0.0 or self.request_time == 0.0:
return 0.0
return self.play_start_time - self.request_time
def elapsed_since_play_s(self) -> float:
if self.play_start_time == 0.0:
return 0.0
return time.monotonic() - self.play_start_time

View File

@@ -71,10 +71,18 @@ def stop_and_print() -> None:
output_path = Path(f"{_state.label}-profile.html")
output_path.write_text(_state.profiler.output_html(), encoding="utf-8")
text_path = Path(f"{_state.label}-profile.txt")
text_path.write_text(_state.profiler.output_text(color=False), encoding="utf-8")
print(
f"\n[profiler:{_state.label}] Saved HTML profile to {output_path.resolve()}",
file=sys.stderr,
)
print(
f"[profiler:{_state.label}] Saved text profile to {text_path.resolve()}",
file=sys.stderr,
)
print(_state.profiler.output_text(color=True), file=sys.stderr)
_state.profiler = None

View File

@@ -191,7 +191,7 @@ def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]:
if keybindings_path.exists():
content = read_safe(keybindings_path)
content = read_safe(keybindings_path).text
return _parse_keybindings(content)
keybindings_path.parent.mkdir(parents=True, exist_ok=True)
return []

View File

@@ -370,6 +370,7 @@ class VibeApp(App): # noqa: PLR0904
self._rewind_mode = False
self._rewind_highlighted_widget: UserMessage | None = None
self._fatal_init_error = False
@property
def config(self) -> VibeConfig:
@@ -449,6 +450,8 @@ class VibeApp(App): # noqa: PLR0904
self.call_after_refresh(self._refresh_banner)
self.run_worker(self._watch_init_completion(), exclusive=False)
if self._show_resume_picker:
self.run_worker(self._show_session_picker(), exclusive=False)
elif self._initial_prompt or self._teleport_on_start:
@@ -457,6 +460,37 @@ class VibeApp(App): # noqa: PLR0904
gc.collect()
gc.freeze()
async def _watch_init_completion(self) -> None:
"""Show 'Initializing' loading indicator until background init finishes."""
init_widget = None
try:
if not self.agent_loop.is_initialized:
await self._ensure_loading_widget("Initializing")
init_widget = self._loading_widget
await self.agent_loop.wait_for_init()
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(
f"Background initialization failed: {e}",
collapsed=self._tools_collapsed,
)
)
await self._mount_and_scroll(
Static("Press any key to exit...", classes="error-hint")
)
if self._chat_input_container:
self._chat_input_container.disabled = True
self._chat_input_container.display = False
self._fatal_init_error = True
finally:
if self._loading_widget is init_widget:
await self._remove_loading_widget()
self._refresh_banner()
try:
self.query_one(MCPApp).refresh_index()
except Exception:
pass
def _process_initial_prompt(self) -> None:
if self._teleport_on_start:
self.run_worker(
@@ -470,6 +504,10 @@ class VibeApp(App): # noqa: PLR0904
def _is_file_watcher_enabled(self) -> bool:
return self.config.file_watcher_for_autocomplete
def on_key(self) -> None:
if self._fatal_init_error:
self.exit()
async def on_chat_input_container_submitted(
self, event: ChatInputContainer.Submitted
) -> None:
@@ -754,7 +792,7 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
try:
skill_content = read_safe(skill_info.skill_path)
skill_content = read_safe(skill_info.skill_path).text
except OSError as e:
await self._mount_and_scroll(
ErrorMessage(
@@ -1059,9 +1097,17 @@ class VibeApp(App): # noqa: PLR0904
self._agent_running = True
await self._remove_loading_widget()
await self._ensure_loading_widget()
try:
show_init_spinner = not self.agent_loop.is_initialized
if show_init_spinner:
await self._ensure_loading_widget("Initializing")
await self.agent_loop.wait_for_init()
if show_init_spinner:
await self._remove_loading_widget()
self._refresh_banner()
await self._ensure_loading_widget()
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
self._narrator_manager.cancel()
self._narrator_manager.on_turn_start(rendered_prompt)
@@ -1088,6 +1134,11 @@ class VibeApp(App): # noqa: PLR0904
except Exception as e:
await self._handle_turn_error()
# _watch_init_completion already rendered the fatal startup error
# and told the user to exit -- don't duplicate the message.
if self._fatal_init_error:
return
message = str(e)
if isinstance(e, RateLimitError):
message = self._rate_limit_message()
@@ -2542,7 +2593,9 @@ class VibeApp(App): # noqa: PLR0904
def _make_default_narrator_manager(self) -> NarratorManager:
return NarratorManager(
config_getter=lambda: self.config, audio_player=AudioPlayer()
config_getter=lambda: self.config,
audio_player=AudioPlayer(),
telemetry_client=self.agent_loop.telemetry_client,
)

View File

@@ -26,7 +26,7 @@ class ExternalEditor:
parts = shlex.split(editor)
subprocess.run([*parts, filepath], check=True)
content = read_safe(Path(filepath)).rstrip()
content = read_safe(Path(filepath)).text.rstrip()
return content if content != initial_content else None
except (OSError, subprocess.CalledProcessError):
return

View File

@@ -68,6 +68,8 @@ class Banner(Static):
self.state = self._initial_state
def watch_state(self) -> None:
if not self.is_mounted:
return
self.query_one("#banner-model", NoMarkupStatic).update(self.state.active_model)
self.query_one("#banner-meta-counts", NoMarkupStatic).update(
self._format_meta_counts()

View File

@@ -78,6 +78,11 @@ class MCPApp(Container):
self._refresh_view(self._viewing_server)
self.query_one(OptionList).focus()
def refresh_index(self) -> None:
"""Re-snapshot the tool index (e.g. after deferred MCP discovery)."""
self._index = collect_mcp_tool_index(self._mcp_servers, self._tool_manager)
self._refresh_view(self._viewing_server)
def on_descendant_blur(self, _event: DescendantBlur) -> None:
self.query_one(OptionList).focus()

View File

@@ -15,7 +15,7 @@ class TeleportMessage(StatusMessage):
if self._error:
return f"Teleport failed: {self._error}"
if self._final_url:
return f"Teleported to Nuage: {self._final_url}"
return f"Teleported to a new async coding session: {self._final_url}"
return self._status
def set_status(self, status: str) -> None:

View File

@@ -24,7 +24,7 @@ def load_whats_new_content() -> str | None:
if not whats_new_file.exists():
return None
try:
content = read_safe(whats_new_file).strip()
content = read_safe(whats_new_file).text.strip()
return content if content else None
except OSError:
return None

View File

@@ -3,9 +3,11 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable, Generator
import contextlib
import copy
from enum import StrEnum, auto
from http import HTTPStatus
from pathlib import Path
import threading
from threading import Thread
import time
from typing import TYPE_CHECKING, Any, Literal
@@ -152,6 +154,7 @@ class AgentLoop:
def __init__(
self,
config: VibeConfig,
*,
agent_name: str = BuiltinAgentName.DEFAULT,
message_observer: Callable[[LLMMessage], None] | None = None,
max_turns: int | None = None,
@@ -160,8 +163,12 @@ class AgentLoop:
enable_streaming: bool = False,
entrypoint_metadata: EntrypointMetadata | None = None,
is_subagent: bool = False,
defer_heavy_init: bool = False,
) -> None:
self._base_config = config
self._init_complete = threading.Event()
self._init_error: Exception | None = None
self.mcp_registry = MCPRegistry()
self.agent_manager = AgentManager(
lambda: self._base_config,
@@ -169,7 +176,9 @@ class AgentLoop:
allow_subagent=is_subagent,
)
self.tool_manager = ToolManager(
lambda: self.config, mcp_registry=self.mcp_registry
lambda: self.config,
mcp_registry=self.mcp_registry,
defer_mcp=defer_heavy_init,
)
self.skill_manager = SkillManager(lambda: self.config)
self.message_observer = message_observer
@@ -190,11 +199,18 @@ class AgentLoop:
self._setup_middleware()
system_prompt = get_universal_system_prompt(
self.tool_manager, self.config, self.skill_manager, self.agent_manager
self.tool_manager,
self.config,
self.skill_manager,
self.agent_manager,
include_git_status=not defer_heavy_init,
)
system_message = LLMMessage(role=Role.system, content=system_prompt)
self.messages = MessageList(initial=[system_message], observer=message_observer)
if not defer_heavy_init:
self._init_complete.set()
self.stats = AgentStats()
self.approval_callback: ApprovalCallback | None = None
self.user_input_callback: UserInputCallback | None = None
@@ -233,6 +249,55 @@ class AgentLoop:
)
thread.start()
def start_deferred_init(self) -> threading.Thread:
"""Spawn a daemon thread that finishes deferred heavy I/O.
Returns the started thread so callers can join if needed.
"""
thread = threading.Thread(
target=self._complete_init, daemon=True, name="agent_loop_init"
)
thread.start()
return thread
@property
def is_initialized(self) -> bool:
"""Whether deferred initialization has completed (successfully or not)."""
return self._init_complete.is_set()
def _complete_init(self) -> None:
"""Run deferred heavy I/O: MCP discovery and git status.
Intended to be called from a background thread when
``defer_heavy_init=True`` was passed to ``__init__``.
"""
try:
self.tool_manager.integrate_mcp(raise_on_failure=True)
system_prompt = get_universal_system_prompt(
self.tool_manager, self.config, self.skill_manager, self.agent_manager
)
self.messages.update_system_prompt(system_prompt)
except Exception as exc:
self._init_error = exc
finally:
self._init_complete.set()
async def wait_for_init(self) -> None:
"""Await deferred initialization from an async context.
If deferred init failed, all callers raise the stored error.
A copy of the stored exception is raised each time so that concurrent
callers do not share (and mutate) the same ``__traceback__`` object.
"""
if self.is_initialized:
if err := self._init_error:
raise copy.copy(err).with_traceback(err.__traceback__)
return
await asyncio.to_thread(self._init_complete.wait)
if err := self._init_error:
raise copy.copy(err).with_traceback(err.__traceback__)
@property
def agent_profile(self) -> AgentProfile:
return self.agent_manager.active_profile
@@ -272,7 +337,7 @@ class AgentLoop:
self.config.tools[tool_name]["permission"] = permission.value
def add_session_rule(self, rule: ApprovedRule) -> None:
def _add_session_rule(self, rule: ApprovedRule) -> None:
self._session_rules.append(rule)
def _is_permission_covered(self, tool_name: str, rp: RequiredPermission) -> bool:
@@ -292,7 +357,7 @@ class AgentLoop:
"""Handle 'Allow Always' approval: add session rules or set tool-level permission."""
if required_permissions:
for rp in required_permissions:
self.add_session_rule(
self._add_session_rule(
ApprovedRule(
tool_name=tool_name,
scope=rp.scope,

View File

@@ -113,7 +113,7 @@ class IgnoreRules:
gitignore_path = root / ".gitignore"
if gitignore_path.exists():
try:
text = read_safe(gitignore_path)
text = read_safe(gitignore_path).text
except Exception:
return patterns

View File

@@ -9,6 +9,7 @@ from vibe.core.autocompletion.path_prompt import (
PathResource,
build_path_prompt_payload,
)
from vibe.core.utils.io import decode_safe
DEFAULT_MAX_EMBED_BYTES = 256 * 1024
@@ -67,11 +68,7 @@ def _try_embed_text_resource(
if not _is_probably_text(resource, data):
return None
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
return None
text = decode_safe(data).text
return {"type": "resource", "uri": resource.path.as_uri(), "text": text}

View File

@@ -11,6 +11,7 @@ from typing import Annotated, Any, Literal
from urllib.parse import urljoin
from dotenv import dotenv_values
from mistralai.client.models import SpeechOutputFormat
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
DEFAULT_TRACES_EXPORT_PATH,
)
@@ -22,6 +23,7 @@ from pydantic_settings import (
PydanticBaseSettingsSource,
SettingsConfigDict,
)
from pydantic_settings.sources.base import deep_update
import tomli_w
from vibe.core.config.harness_files import get_harness_files_manager
@@ -96,6 +98,29 @@ class TomlFileSettingsSource(PydanticBaseSettingsSource):
return self.toml_data
def _remove_none_values(value: Any) -> Any:
if isinstance(value, dict):
return {
key: cleaned_value
for key, item in value.items()
if (cleaned_value := _remove_none_values(item)) is not None
}
if isinstance(value, list):
return [
cleaned_item
for item in value
if (cleaned_item := _remove_none_values(item)) is not None
]
return value
def _to_toml_document(value: Any) -> dict[str, Any]:
jsonable = to_jsonable_python(value, fallback=str)
if not isinstance(jsonable, dict):
return {}
return _remove_none_values(jsonable)
class ProjectContextConfig(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
@@ -121,16 +146,55 @@ class SessionLoggingConfig(BaseSettings):
return str(Path(v).expanduser().resolve())
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"
class ProviderConfig(BaseModel):
name: str
api_base: str
api_key_env_var: str = ""
browser_auth_base_url: str | None = None
browser_auth_api_base_url: str | None = None
api_style: str = "openai"
backend: Backend = Backend.GENERIC
reasoning_field_name: str = "reasoning_content"
project_id: str = ""
region: str = ""
def _is_legacy_mistral_provider_without_backend(self) -> bool:
return (
self.name == "mistral"
and self.backend == Backend.GENERIC
and "backend" not in self.model_fields_set
)
def _uses_mistral_browser_sign_in_defaults(self) -> bool:
return self.name == "mistral" and (
self.backend == Backend.MISTRAL
or self._is_legacy_mistral_provider_without_backend()
)
@model_validator(mode="after")
def _apply_legacy_mistral_browser_auth_defaults(self) -> ProviderConfig:
if not self._uses_mistral_browser_sign_in_defaults():
return self
if self.browser_auth_base_url is None:
self.browser_auth_base_url = DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL
if self.browser_auth_api_base_url is None:
self.browser_auth_api_base_url = DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL
return self
@property
def supports_browser_sign_in(self) -> bool:
return (
(self.backend == Backend.MISTRAL or self.name == "mistral")
and bool(self.browser_auth_base_url)
and bool(self.browser_auth_api_base_url)
)
class TranscribeClient(StrEnum):
MISTRAL = auto()
@@ -289,7 +353,7 @@ class TTSModelConfig(BaseModel):
provider: str
alias: str
voice: str = "gb_jane_neutral"
response_format: str = "wav"
response_format: SpeechOutputFormat = "wav"
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
@@ -301,7 +365,6 @@ class OtelSpanExporterConfig(BaseModel):
headers: dict[str, str] | None = None
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
MISTRAL_OTEL_PATH = "/telemetry"
_DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai"
@@ -310,6 +373,8 @@ DEFAULT_PROVIDERS = [
name="mistral",
api_base=f"{_DEFAULT_MISTRAL_SERVER_URL}/v1",
api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY,
browser_auth_base_url=DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL,
browser_auth_api_base_url=DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL,
backend=Backend.MISTRAL,
),
ProviderConfig(
@@ -343,6 +408,8 @@ DEFAULT_MODELS = [
),
]
DEFAULT_ACTIVE_MODEL = DEFAULT_MODELS[0].alias
DEFAULT_TRANSCRIBE_PROVIDERS = [
TranscribeProviderConfig(
name="mistral",
@@ -375,7 +442,7 @@ DEFAULT_TTS_MODELS = [
class VibeConfig(BaseSettings):
active_model: str = "devstral-2"
active_model: str = DEFAULT_ACTIVE_MODEL
vim_keybindings: bool = False
disable_welcome_banner_animation: bool = False
autocopy_to_clipboard: bool = True
@@ -529,7 +596,8 @@ class VibeConfig(BaseSettings):
def otel_span_exporter_config(self) -> OtelSpanExporterConfig | None:
# When otel_endpoint is set explicitly, authentication is the user's responsibility
# (via OTEL_EXPORTER_OTLP_* env vars), so headers are left empty.
# Otherwise endpoint and API key are derived from the first MISTRAL provider.
# Otherwise endpoint and API key are derived from the active provider if it's Mistral,
# or the first Mistral provider.
traces_export_path = DEFAULT_TRACES_EXPORT_PATH.lstrip("/")
if self.otel_endpoint:
return OtelSpanExporterConfig(
@@ -538,9 +606,7 @@ class VibeConfig(BaseSettings):
)
)
provider = next(
(p for p in self.providers if p.backend == Backend.MISTRAL), None
)
provider = self.get_mistral_provider()
if provider is not None:
server_url = get_server_url_from_api_base(provider.api_base)
@@ -578,7 +644,7 @@ class VibeConfig(BaseSettings):
".md"
)
if custom_sp_path.is_file():
return read_safe(custom_sp_path)
return read_safe(custom_sp_path).text
raise MissingPromptFileError(
self.system_prompt_id, *(str(d) for d in prompt_dirs)
@@ -597,6 +663,15 @@ class VibeConfig(BaseSettings):
return self.compaction_model
return self.get_active_model()
def get_mistral_provider(self) -> ProviderConfig | None:
try:
active_provider = self.get_provider_for_model(self.get_active_model())
if active_provider.backend == Backend.MISTRAL:
return active_provider
except ValueError:
pass
return next((p for p in self.providers if p.backend == Backend.MISTRAL), None)
def get_provider_for_model(self, model: ModelConfig) -> ProviderConfig:
for provider in self.providers:
if provider.name == model.provider:
@@ -776,37 +851,8 @@ class VibeConfig(BaseSettings):
if not get_harness_files_manager().persist_allowed:
return
current_config = TomlFileSettingsSource(cls).toml_data
def deep_merge(target: dict, source: dict) -> None:
for key, value in source.items():
if (
key in target
and isinstance(target.get(key), dict)
and isinstance(value, dict)
):
deep_merge(target[key], value)
elif (
key in target
and isinstance(target.get(key), list)
and isinstance(value, list)
):
if key in {
"providers",
"models",
"transcribe_providers",
"transcribe_models",
"tts_providers",
"tts_models",
"installed_agents",
}:
target[key] = value
else:
target[key] = list(set(value + target[key]))
else:
target[key] = value
deep_merge(current_config, updates)
cls.dump_config(current_config)
merged_config = deep_update(current_config, updates)
cls.dump_config(merged_config)
@classmethod
def dump_config(cls, config: dict[str, Any]) -> None:
@@ -815,8 +861,10 @@ class VibeConfig(BaseSettings):
return
target = mgr.config_file or mgr.user_config_file
target.parent.mkdir(parents=True, exist_ok=True)
toml_document = _to_toml_document(config)
cls.model_validate(toml_document)
with target.open("wb") as f:
tomli_w.dump(to_jsonable_python(config, exclude_none=True, fallback=str), f)
tomli_w.dump(toml_document, f)
@classmethod
def _migrate(cls) -> None:

View File

@@ -10,7 +10,12 @@ from vibe.core.config.harness_files._paths import (
GLOBAL_SKILLS_DIR,
GLOBAL_TOOLS_DIR,
)
from vibe.core.paths import AGENTS_MD_FILENAME, VIBE_HOME, walk_local_config_dirs_all
from vibe.core.paths import (
AGENTS_MD_FILENAME,
VIBE_HOME,
ConfigWalkResult,
walk_local_config_dirs,
)
from vibe.core.trusted_folders import trusted_folders_manager
from vibe.core.utils.io import read_safe
@@ -72,25 +77,23 @@ class HarnessFilesManager:
d = GLOBAL_AGENTS_DIR.path
return [d] if d.is_dir() else []
def _walk_project_dirs(
self,
) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]:
def _walk_project_dirs(self) -> ConfigWalkResult:
workdir = self.trusted_workdir
if workdir is None:
return ((), (), ())
return walk_local_config_dirs_all(workdir)
return ConfigWalkResult()
return walk_local_config_dirs(workdir)
@property
def project_tools_dirs(self) -> list[Path]:
return list(self._walk_project_dirs()[0])
return list(self._walk_project_dirs().tools)
@property
def project_skills_dirs(self) -> list[Path]:
return list(self._walk_project_dirs()[1])
return list(self._walk_project_dirs().skills)
@property
def project_agents_dirs(self) -> list[Path]:
return list(self._walk_project_dirs()[2])
return list(self._walk_project_dirs().agents)
@property
def user_config_file(self) -> Path:
@@ -116,7 +119,7 @@ class HarnessFilesManager:
return ""
path = VIBE_HOME.path / AGENTS_MD_FILENAME
try:
stripped = read_safe(path).strip()
stripped = read_safe(path).text.strip()
return stripped if stripped else ""
except (FileNotFoundError, OSError):
return ""
@@ -140,7 +143,7 @@ class HarnessFilesManager:
break
path = current / AGENTS_MD_FILENAME
try:
stripped = read_safe(path).strip()
stripped = read_safe(path).text.strip()
if stripped:
docs.append((current, stripped))
except (FileNotFoundError, OSError):

View File

@@ -454,7 +454,7 @@ class AnthropicAdapter(APIAdapter):
return payload
def prepare_request( # noqa: PLR0913
def prepare_request(
self,
*,
model_name: str,

View File

@@ -19,7 +19,7 @@ class PreparedRequest(NamedTuple):
class APIAdapter(Protocol):
endpoint: ClassVar[str]
def prepare_request( # noqa: PLR0913
def prepare_request(
self,
*,
model_name: str,

View File

@@ -78,7 +78,7 @@ class OpenAIAdapter(APIAdapter):
msg_dict["reasoning_content"] = msg_dict.pop(field_name)
return msg_dict
def prepare_request( # noqa: PLR0913
def prepare_request(
self,
*,
model_name: str,

View File

@@ -107,7 +107,7 @@ class ReasoningAdapter(APIAdapter):
return payload
def prepare_request( # noqa: PLR0913
def prepare_request(
self,
*,
model_name: str,

View File

@@ -64,7 +64,7 @@ class VertexAnthropicAdapter(AnthropicAdapter):
super().__init__()
self.credentials = VertexCredentials()
def prepare_request( # noqa: PLR0913
def prepare_request(
self,
*,
model_name: str,

View File

@@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator, Callable
import json
from typing import Any, cast
from jsonpatch import JsonPatch, JsonPatchException # type: ignore[import-untyped]
from pydantic import BaseModel, ValidationError
from vibe.core.nuage.agent_models import AgentCompletionState
@@ -16,7 +17,6 @@ from vibe.core.nuage.events import (
CustomTaskTimedOut,
JSONPatchAppend,
JSONPatchPayload,
JSONPatchRemove,
JSONPatchReplace,
JSONPayload,
WorkflowEvent,
@@ -61,11 +61,57 @@ from vibe.core.types import (
)
_WAIT_FOR_INPUT_TASK_TYPE = "wait_for_input"
_STEER_INPUT_LABEL = "Send a message to steer..."
# These names must match the remote workflow's tool naming convention
_ASK_USER_QUESTION_TOOL = "ask_user_question"
_SEND_USER_MESSAGE_TOOL = "send_user_message"
def _get_value_at_path(path: str, obj: Any) -> Any:
if not path or path == "/":
return obj
parts = path.split("/")[1:]
current = obj
for part in parts:
if current is None:
return None
if isinstance(current, dict) and part in current:
current = current[part]
elif isinstance(current, list):
try:
current = current[int(part)]
except (ValueError, IndexError):
return None
else:
return None
return current
def _set_value_at_path(path: str, obj: Any, value: Any) -> None:
if not path or path == "/":
return
parts = path.split("/")[1:]
current = obj
for part in parts[:-1]:
if isinstance(current, dict) and part in current:
current = current[part]
elif isinstance(current, list):
try:
current = current[int(part)]
except (ValueError, IndexError):
return
else:
return
last = parts[-1]
if isinstance(current, dict):
current[last] = value
elif isinstance(current, list):
try:
current[int(last)] = value
except (ValueError, IndexError):
pass
class _RemoteTool(
BaseTool[RemoteToolArgs, RemoteToolResult, BaseToolConfig, BaseToolState],
ToolUIData[RemoteToolArgs, RemoteToolResult],
@@ -146,6 +192,7 @@ class RemoteWorkflowEventTranslator:
self._pending_input_request: PendingInputRequest | None = None
self._pending_question_prompt: str | None = None
self._pending_ask_user_question_call_id: str | None = None
self._steer_task_ids: set[str] = set()
self._last_status: WorkflowExecutionStatus | None = None
@property
@@ -315,12 +362,18 @@ class RemoteWorkflowEventTranslator:
payload_value = event.attributes.payload.value
label: str | None = None
if isinstance(payload_value, dict):
label = payload_value.get("label")
if label == _STEER_INPUT_LABEL:
self._steer_task_ids.add(event.attributes.custom_task_id)
return []
if isinstance(payload_value, dict):
self._pending_input_request = PendingInputRequest.model_validate({
"task_id": event.attributes.custom_task_id,
**payload_value,
})
label = self._pending_input_request.label
events: list[BaseEvent] = []
if label:
@@ -345,6 +398,10 @@ class RemoteWorkflowEventTranslator:
if event.attributes.custom_task_type != _WAIT_FOR_INPUT_TASK_TYPE:
return None
if event.attributes.custom_task_id in self._steer_task_ids:
self._steer_task_ids.discard(event.attributes.custom_task_id)
return []
self._pending_input_request = None
self._pending_question_prompt = None
ask_user_question_call_id = self._pending_ask_user_question_call_id
@@ -431,53 +488,25 @@ class RemoteWorkflowEventTranslator:
self, previous_state: dict[str, Any], payload: JSONPatchPayload
) -> dict[str, Any]:
new_state = cast(dict[str, Any], self._json_safe_value(previous_state))
for patch in payload.value:
path = [part for part in patch.path.split("/") if part]
if not path:
if isinstance(patch, JSONPatchReplace):
new_state = self._normalize_state(patch.value)
continue
if isinstance(patch, JSONPatchRemove):
self._remove_path(new_state, path)
continue
if isinstance(patch, JSONPatchAppend):
current = self._get_path(new_state, path)
self._set_path(new_state, path, f"{current or ''}{patch.value}")
continue
self._set_path(new_state, path, patch.value)
current = _get_value_at_path(patch.path, new_state)
_set_value_at_path(
patch.path, new_state, f"{current or ''}{patch.value}"
)
elif isinstance(patch, JSONPatchReplace) and not patch.path.strip("/"):
new_state = self._normalize_state(patch.value)
else:
try:
new_state = JsonPatch([
{"op": patch.op, "path": patch.path, "value": patch.value}
]).apply(new_state)
except JsonPatchException:
pass
return new_state
def _get_path(self, state: dict[str, Any], path: list[str]) -> Any:
current: Any = state
for part in path:
if not isinstance(current, dict):
return None
current = current.get(part)
return current
def _set_path(self, state: dict[str, Any], path: list[str], value: Any) -> None:
current: dict[str, Any] = state
for part in path[:-1]:
child = current.get(part)
if not isinstance(child, dict):
child = {}
current[part] = child
current = child
current[path[-1]] = value
def _remove_path(self, state: dict[str, Any], path: list[str]) -> None:
current: dict[str, Any] = state
for part in path[:-1]:
child = current.get(part)
if not isinstance(child, dict):
return
current = child
current.pop(path[-1], None)
def _completion_events(
self, task_id: str, previous_state: dict[str, Any], state: dict[str, Any]
) -> list[BaseEvent]:

View File

@@ -2,8 +2,8 @@ from __future__ import annotations
from vibe.core.paths._local_config_walk import (
WALK_MAX_DEPTH,
has_config_dirs_nearby,
walk_local_config_dirs_all,
ConfigWalkResult,
walk_local_config_dirs,
)
from vibe.core.paths._vibe_home import (
DEFAULT_TOOL_DIR,
@@ -31,7 +31,7 @@ __all__ = [
"TRUSTED_FOLDERS_FILE",
"VIBE_HOME",
"WALK_MAX_DEPTH",
"ConfigWalkResult",
"GlobalPath",
"has_config_dirs_nearby",
"walk_local_config_dirs_all",
"walk_local_config_dirs",
]

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from collections import deque
from dataclasses import dataclass, field
from functools import cache
import logging
import os
@@ -21,24 +22,59 @@ WALK_MAX_DEPTH = 4
_MAX_DIRS = 2000
def _collect_config_dirs_at(
path: Path,
entry_names: set[str],
tools: list[Path],
skills: list[Path],
agents: list[Path],
@dataclass(frozen=True)
class ConfigWalkResult:
"""Aggregated results of a config directory walk."""
config_dirs: tuple[Path, ...] = ()
tools: tuple[Path, ...] = ()
skills: tuple[Path, ...] = ()
agents: tuple[Path, ...] = ()
@dataclass
class _ConfigWalkCollector:
"""Mutable accumulator used during BFS, frozen into ConfigWalkResult at the end."""
config_dirs: list[Path] = field(default_factory=list)
tools: list[Path] = field(default_factory=list)
skills: list[Path] = field(default_factory=list)
agents: list[Path] = field(default_factory=list)
def freeze(self) -> ConfigWalkResult:
return ConfigWalkResult(
config_dirs=tuple(self.config_dirs),
tools=tuple(self.tools),
skills=tuple(self.skills),
agents=tuple(self.agents),
)
def _collect_at(
path: Path, entry_names: set[str], collector: _ConfigWalkCollector
) -> None:
"""Check a single directory for .vibe/ and .agents/ config subdirs."""
if _VIBE_DIR in entry_names:
if _VIBE_DIR in entry_names and (vibe_dir := path / _VIBE_DIR).is_dir():
has_content = False
if (candidate := path / _TOOLS_SUBDIR).is_dir():
tools.append(candidate)
collector.tools.append(candidate)
has_content = True
if (candidate := path / _VIBE_SKILLS_SUBDIR).is_dir():
skills.append(candidate)
collector.skills.append(candidate)
has_content = True
if (candidate := path / _AGENTS_SUBDIR).is_dir():
agents.append(candidate)
if _AGENTS_DIR in entry_names:
collector.agents.append(candidate)
has_content = True
if (
has_content
or (vibe_dir / "prompts").is_dir()
or (vibe_dir / "config.toml").is_file()
):
collector.config_dirs.append(vibe_dir)
if _AGENTS_DIR in entry_names and (agents_dir := path / _AGENTS_DIR).is_dir():
if (candidate := path / _AGENTS_SKILLS_SUBDIR).is_dir():
skills.append(candidate)
collector.skills.append(candidate)
collector.config_dirs.append(agents_dir)
def _scandir_entries(path: Path) -> tuple[set[str], list[Path]]:
@@ -68,60 +104,22 @@ def _scandir_entries(path: Path) -> tuple[set[str], list[Path]]:
@cache
def walk_local_config_dirs_all(
root: Path,
) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]:
def walk_local_config_dirs(
root: Path, *, max_depth: int = WALK_MAX_DEPTH, max_dirs: int = _MAX_DIRS
) -> ConfigWalkResult:
"""Discover .vibe/ and .agents/ config directories under *root*.
Uses breadth-first search bounded by ``WALK_MAX_DEPTH`` and ``_MAX_DIRS``
Uses breadth-first search bounded by *max_depth* and *max_dirs*
to avoid unbounded traversal in large repositories.
"""
tools_dirs: list[Path] = []
skills_dirs: list[Path] = []
agents_dirs: list[Path] = []
Returns a ``ConfigWalkResult`` containing both the parent config dirs
(for trust decisions) and the categorised subdirs (for loading).
"""
collector = _ConfigWalkCollector()
resolved_root = root.resolve()
queue: deque[tuple[Path, int]] = deque([(resolved_root, 0)])
visited = 0
while queue and visited < _MAX_DIRS:
current, depth = queue.popleft()
visited += 1
entry_names, children = _scandir_entries(current)
if not entry_names:
continue
_collect_config_dirs_at(
current, entry_names, tools_dirs, skills_dirs, agents_dirs
)
if depth < WALK_MAX_DEPTH:
queue.extend((child, depth + 1) for child in children)
if visited >= _MAX_DIRS:
logger.warning(
"Config directory scan reached directory limit (%d dirs) at %s",
_MAX_DIRS,
resolved_root,
)
return (tuple(tools_dirs), tuple(skills_dirs), tuple(agents_dirs))
def has_config_dirs_nearby(
root: Path, *, max_depth: int = WALK_MAX_DEPTH, max_dirs: int = 200
) -> bool:
"""Quick check for .vibe/ or .agents/ config dirs in the near subtree.
Returns ``True`` as soon as any config directory is found, without
enumerating all of them.
"""
resolved = root.resolve()
queue: deque[tuple[Path, int]] = deque([(resolved, 0)])
visited = 0
found: list[Path] = []
while queue and visited < max_dirs:
current, depth = queue.popleft()
visited += 1
@@ -130,11 +128,16 @@ def has_config_dirs_nearby(
if not entry_names:
continue
_collect_config_dirs_at(current, entry_names, found, found, found)
if found:
return True
_collect_at(current, entry_names, collector)
if depth < max_depth:
queue.extend((child, depth + 1) for child in children)
return False
if visited >= max_dirs:
logger.warning(
"Config directory scan reached directory limit (%d dirs) at %s",
max_dirs,
resolved_root,
)
return collector.freeze()

View File

@@ -15,7 +15,7 @@ class Prompt(StrEnum):
return (_PROMPTS_DIR / self.value).with_suffix(".md")
def read(self) -> str:
return read_safe(self.path).strip()
return read_safe(self.path).text.strip()
class SystemPrompt(Prompt):

View File

@@ -34,21 +34,19 @@ class SessionLoader:
return False
try:
with metadata_path.open("r", encoding="utf-8", errors="ignore") as f:
metadata = json.load(f)
metadata = json.loads(read_safe(metadata_path).text)
if not isinstance(metadata, dict):
return False
with messages_path.open("r", encoding="utf-8", errors="ignore") as f:
has_messages = False
for line in f:
has_messages = True
message = json.loads(line)
if not isinstance(message, dict):
return False
has_messages = False
for line in read_safe(messages_path).text.splitlines():
has_messages = True
message = json.loads(line)
if not isinstance(message, dict):
return False
if not has_messages:
return False
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError):
return False
return True
@@ -144,8 +142,7 @@ class SessionLoader:
metadata_path = session_dir / METADATA_FILENAME
try:
with metadata_path.open("r", encoding="utf-8") as f:
metadata = json.load(f)
metadata = json.loads(read_safe(metadata_path).text)
except (OSError, json.JSONDecodeError):
continue
@@ -182,7 +179,7 @@ class SessionLoader:
raise ValueError(f"Session metadata not found at {session_dir}")
try:
metadata_content = read_safe(metadata_path)
metadata_content = read_safe(metadata_path).text
return SessionMetadata.model_validate_json(metadata_content)
except ValueError:
raise
@@ -197,7 +194,7 @@ class SessionLoader:
messages_filepath = filepath / MESSAGES_FILENAME
try:
content = read_safe(messages_filepath).split("\n")
content = read_safe(messages_filepath).text.split("\n")
if content and content[-1] == "":
content.pop()
except Exception as e:
@@ -228,10 +225,7 @@ class SessionLoader:
if metadata_filepath.exists():
try:
with metadata_filepath.open(
"r", encoding="utf-8", errors="ignore"
) as f:
metadata = json.load(f)
metadata = json.loads(read_safe(metadata_filepath).text)
except json.JSONDecodeError as e:
raise ValueError(
f"Session metadata contains invalid JSON (may have been corrupted): "

View File

@@ -231,7 +231,7 @@ class SessionLogger:
# Read old metadata and get total_messages
try:
if self.metadata_filepath.exists():
raw = await read_safe_async(self.metadata_filepath)
raw = (await read_safe_async(self.metadata_filepath)).text
old_metadata = json.loads(raw)
old_total_messages = old_metadata["total_messages"]
else:

View File

@@ -23,7 +23,7 @@ async def migrate_sessions(session_config: SessionLoggingConfig) -> int:
session_files = list(Path(save_dir).glob(f"{session_config.session_prefix}_*.json"))
for session_file in session_files:
try:
session_data = read_safe(session_file)
session_data = read_safe(session_file).text
session_json = json.loads(session_data)
metadata = session_json["metadata"]
messages = session_json["messages"]

View File

@@ -107,7 +107,7 @@ class SkillManager:
def _parse_skill_file(self, skill_path: Path) -> SkillInfo:
try:
content = read_safe(skill_path)
content = read_safe(skill_path).text
except OSError as e:
raise SkillParseError(f"Cannot read file: {e}") from e

View File

@@ -136,8 +136,8 @@ class ProjectContextProvider:
except Exception as e:
return f"Error getting git status: {e}"
def get_full_context(self) -> str:
git_status = self.get_git_status()
def get_full_context(self, *, include_git_status: bool = True) -> str:
git_status = self.get_git_status() if include_git_status else ""
template = UtilityPrompt.PROJECT_CONTEXT.read()
return Template(template).safe_substitute(
@@ -249,6 +249,8 @@ def get_universal_system_prompt(
config: VibeConfig,
skill_manager: SkillManager,
agent_manager: AgentManager,
*,
include_git_status: bool = True,
) -> str:
sections = [config.system_prompt]
@@ -285,7 +287,7 @@ def get_universal_system_prompt(
else:
context = ProjectContextProvider(
config=config.project_context, root_path=Path.cwd()
).get_full_context()
).get_full_context(include_git_status=include_git_status)
sections.append(context)

View File

@@ -4,19 +4,20 @@ import asyncio
from collections.abc import Callable
import os
from typing import TYPE_CHECKING, Any, Literal
from urllib.parse import urljoin
import httpx
from vibe import __version__
from vibe.core.config import VibeConfig
from vibe.core.config import ProviderConfig, VibeConfig
from vibe.core.llm.format import ResolvedToolCall
from vibe.core.types import Backend
from vibe.core.utils import get_user_agent
from vibe.core.utils import get_server_url_from_api_base, get_user_agent
if TYPE_CHECKING:
from vibe.core.agent_loop import ToolDecision
DATALAKE_EVENTS_URL = "https://codestral.mistral.ai/v1/datalake/events"
_DEFAULT_TELEMETRY_BASE_URL = "https://codestral.mistral.ai"
_DATALAKE_EVENTS_PATH = "/v1/datalake/events"
class TelemetryClient:
@@ -31,31 +32,35 @@ class TelemetryClient:
self._pending_tasks: set[asyncio.Task[Any]] = set()
self.last_correlation_id: str | None = None
def _get_telemetry_user_agent(self) -> str:
try:
config = self._config_getter()
active_model = config.get_active_model()
provider = config.get_provider_for_model(active_model)
return get_user_agent(provider.backend)
except ValueError:
return get_user_agent(None)
def _get_telemetry_url(self, api_base: str) -> str:
base = get_server_url_from_api_base(api_base) or _DEFAULT_TELEMETRY_BASE_URL
return urljoin(base.rstrip("/"), _DATALAKE_EVENTS_PATH)
def _get_mistral_api_key(self) -> str | None:
"""Get the current API key from the active provider.
"""Get the API key from the active provider if it's Mistral,
otherwise the first Mistral provider.
Only returns an API key if the provider is a Mistral provider
to avoid leaking third-party credentials to the telemetry endpoint.
"""
provider_and_api_key = self._get_mistral_provider_and_api_key()
if provider_and_api_key is None:
return None
_, api_key = provider_and_api_key
return api_key
def _get_mistral_provider_and_api_key(self) -> tuple[ProviderConfig, str] | None:
try:
config = self._config_getter()
model = config.get_active_model()
provider = config.get_provider_for_model(model)
if provider.backend != Backend.MISTRAL:
return None
env_var = provider.api_key_env_var
return os.getenv(env_var) if env_var else None
provider = self._config_getter().get_mistral_provider()
except ValueError:
return None
if provider is None:
return None
env_var = provider.api_key_env_var
api_key = os.getenv(env_var) if env_var else None
if api_key is None:
return None
return provider, api_key
def _is_enabled(self) -> bool:
"""Check if telemetry is enabled in the current config."""
@@ -83,10 +88,14 @@ class TelemetryClient:
*,
correlation_id: str | None = None,
) -> None:
mistral_api_key = self._get_mistral_api_key()
if mistral_api_key is None or not self._is_enabled():
if not self._is_enabled():
return
user_agent = self._get_telemetry_user_agent()
provider_and_api_key = self._get_mistral_provider_and_api_key()
if provider_and_api_key is None:
return
provider, mistral_api_key = provider_and_api_key
telemetry_url = self._get_telemetry_url(provider.api_base)
user_agent = get_user_agent(provider.backend)
if (
self._session_id_getter is not None
and (session_id := self._session_id_getter()) is not None
@@ -100,7 +109,7 @@ class TelemetryClient:
async def _send() -> None:
try:
await self.client.post(
DATALAKE_EVENTS_URL,
telemetry_url,
json=payload,
headers={
"Content-Type": "application/json",

View File

@@ -134,7 +134,9 @@ class GitRepository:
try:
self._repo = Repo(self._workdir, search_parent_directories=True)
except InvalidGitRepositoryError as e:
raise ServiceTeleportNotSupportedError("Not a git repository") from e
raise ServiceTeleportNotSupportedError(
"Teleport requires a git repository. cd into a project with a .git directory and try again."
) from e
return self._repo
def _find_github_remote(self, repo: Repo) -> tuple[str, str] | None:

View File

@@ -167,7 +167,7 @@ class BaseTool[
prompt_dir = class_path.parent / "prompts"
prompt_path = cls.prompt_path or prompt_dir / f"{class_path.stem}.md"
return read_safe(prompt_path)
return read_safe(prompt_path).text
except (FileNotFoundError, TypeError, OSError):
pass

View File

@@ -75,7 +75,7 @@ class ExitPlanMode(
plan_content: str | None = None
if ctx.plan_file_path and ctx.plan_file_path.is_file():
try:
plan_content = read_safe(ctx.plan_file_path)
plan_content = read_safe(ctx.plan_file_path).text
except OSError as e:
raise ToolError(
f"Failed to read plan file at {ctx.plan_file_path}: {e}"

View File

@@ -167,7 +167,7 @@ class Grep(
def _load_codeignore_patterns(self, codeignore_path: Path) -> list[str]:
patterns = []
try:
content = read_safe(codeignore_path)
content = read_safe(codeignore_path).text
for line in content.splitlines():
line = line.strip()
if line and not line.startswith("#"):

View File

@@ -21,6 +21,7 @@ from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.tools.utils import resolve_file_tool_permission
from vibe.core.types import ToolStreamEvent
from vibe.core.utils import VIBE_WARNING_TAG
from vibe.core.utils.io import decode_safe
if TYPE_CHECKING:
from vibe.core.types import ToolResultEvent
@@ -73,8 +74,8 @@ class ReadFile(
ToolUIData[ReadFileArgs, ReadFileResult],
):
description: ClassVar[str] = (
"Read a UTF-8 file, returning content from a specific line range. "
"Reading is capped by a byte limit for safety."
"Read a text file (encoding detected safely), returning content from a "
"specific line range. Reading is capped by a byte limit for safety."
)
@final
@@ -135,53 +136,38 @@ class ReadFile(
async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult:
try:
return await self._do_read_file(args, file_path, encoding="utf-8")
except (UnicodeDecodeError, ValueError):
return await self._do_read_file(args, file_path, errors="replace")
async def _do_read_file(
self,
args: ReadFileArgs,
file_path: Path,
*,
encoding: str | None = None,
errors: str | None = None,
) -> _ReadResult:
try:
lines_to_return: list[str] = []
raw_lines: list[bytes] = []
bytes_read = 0
was_truncated = False
async with await anyio.Path(file_path).open(
encoding=encoding, errors=errors
) as f:
async with await anyio.Path(file_path).open("rb") as f:
line_index = 0
async for line in f:
while raw_line := await f.readline():
if line_index < args.offset:
line_index += 1
continue
if args.limit is not None and len(lines_to_return) >= args.limit:
if args.limit is not None and len(raw_lines) >= args.limit:
break
line_bytes = len(line.encode("utf-8"))
line_bytes = len(raw_line)
if bytes_read + line_bytes > self.config.max_read_bytes:
was_truncated = True
break
lines_to_return.append(line)
raw_lines.append(raw_line)
bytes_read += line_bytes
line_index += 1
return _ReadResult(
lines=lines_to_return,
bytes_read=bytes_read,
was_truncated=was_truncated,
)
except OSError as exc:
raise ToolError(f"Error reading {file_path}: {exc}") from exc
lines_to_return = decode_safe(b"".join(raw_lines)).text.splitlines(
keepends=True
)
return _ReadResult(
lines=lines_to_return, bytes_read=bytes_read, was_truncated=was_truncated
)
def _validate_inputs(self, args: ReadFileArgs) -> None:
if not args.path.strip():
raise ToolError("Path cannot be empty")

View File

@@ -22,7 +22,7 @@ from vibe.core.tools.permissions import PermissionContext
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.tools.utils import resolve_file_tool_permission
from vibe.core.types import ToolResultEvent, ToolStreamEvent
from vibe.core.utils.io import read_safe_async
from vibe.core.utils.io import ReadSafeResult, read_safe_async
SEARCH_REPLACE_BLOCK_RE = re.compile(
r"<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE", flags=re.DOTALL
@@ -131,7 +131,8 @@ class SearchReplace(
) -> AsyncGenerator[ToolStreamEvent | SearchReplaceResult, None]:
file_path, search_replace_blocks = self._prepare_and_validate_args(args)
original_content = await self._read_file(file_path)
decoded = await self._read_file(file_path)
original_content = decoded.text
block_result = self._apply_blocks(
original_content,
@@ -166,7 +167,7 @@ class SearchReplace(
except Exception:
pass
await self._write_file(file_path, modified_content)
await self._write_file(file_path, modified_content, decoded.encoding)
yield SearchReplaceResult(
file=str(file_path),
@@ -221,7 +222,7 @@ class SearchReplace(
return file_path, search_replace_blocks
async def _read_file(self, file_path: Path) -> str:
async def _read_file(self, file_path: Path) -> ReadSafeResult:
try:
return await read_safe_async(file_path, raise_on_error=True)
except PermissionError:
@@ -234,12 +235,16 @@ class SearchReplace(
async def _backup_file(self, file_path: Path) -> None:
shutil.copy2(file_path, file_path.with_suffix(file_path.suffix + ".bak"))
async def _write_file(self, file_path: Path, content: str) -> None:
async def _write_file(self, file_path: Path, content: str, encoding: str) -> None:
try:
async with await anyio.Path(file_path).open(
mode="w", encoding="utf-8"
mode="w", encoding=encoding
) as f:
await f.write(content)
except UnicodeEncodeError as e:
raise ToolError(
f"Cannot encode patched content for {file_path} using {encoding!r}: {e}"
) from e
except PermissionError:
raise ToolError(f"Permission denied writing to file: {file_path}")
except OSError as e:

View File

@@ -99,7 +99,7 @@ class Skill(
)
try:
raw = read_safe(skill_info.skill_path)
raw = read_safe(skill_info.skill_path).text
_, body = parse_frontmatter(raw)
except (OSError, SkillParseError) as e:
raise ToolError(f"Cannot load skill file: {e}") from e

View File

@@ -7,6 +7,7 @@ import inspect
from pathlib import Path
import re
import sys
import threading
from typing import TYPE_CHECKING, Any
from vibe.core.config.harness_files import get_harness_files_manager
@@ -69,16 +70,21 @@ class ToolManager:
self,
config_getter: Callable[[], VibeConfig],
mcp_registry: MCPRegistry | None = None,
*,
defer_mcp: bool = False,
) -> None:
self._config_getter = config_getter
self._mcp_registry = mcp_registry or MCPRegistry()
self._instances: dict[str, BaseTool] = {}
self._search_paths: list[Path] = self._compute_search_paths(self._config)
self._lock = threading.Lock()
self._mcp_integrated = False
self._available: dict[str, type[BaseTool]] = {
cls.get_name(): cls for cls in self._iter_tool_classes(self._search_paths)
}
self._integrate_mcp()
if not defer_mcp:
self.integrate_mcp()
@property
def _config(self) -> VibeConfig:
@@ -176,13 +182,15 @@ class ToolManager:
@property
def registered_tools(self) -> dict[str, type[BaseTool]]:
return self._available
with self._lock:
return dict(self._available)
@property
def available_tools(self) -> dict[str, type[BaseTool]]:
runtime_available = {
name: cls for name, cls in self._available.items() if cls.is_available()
}
with self._lock:
runtime_available = {
name: cls for name, cls in self._available.items() if cls.is_available()
}
if self._config.enabled_tools:
return {
@@ -198,7 +206,14 @@ class ToolManager:
}
return runtime_available
def _integrate_mcp(self) -> None:
def integrate_mcp(self, *, raise_on_failure: bool = False) -> None:
"""Discover and register MCP tools.
Idempotent: subsequent calls after a successful integration are
no-ops to avoid redundant MCP discovery.
"""
if self._mcp_integrated:
return
if not self._config.mcp_servers:
return
@@ -206,15 +221,20 @@ class ToolManager:
mcp_tools = self._mcp_registry.get_tools(self._config.mcp_servers)
except Exception as exc:
logger.warning("MCP integration failed: %s", exc)
if raise_on_failure:
raise
return
self._available.update(mcp_tools)
with self._lock:
self._available = {**self._available, **mcp_tools}
self._mcp_integrated = True
logger.info(
"MCP integration registered %d tools (via registry)", len(mcp_tools)
)
def get_tool_config(self, tool_name: str) -> BaseToolConfig:
tool_class = self._available.get(tool_name)
with self._lock:
tool_class = self._available.get(tool_name)
if tool_class:
config_class = tool_class._get_tool_config_class()
@@ -239,12 +259,12 @@ class ToolManager:
if tool_name in self._instances:
return self._instances[tool_name]
if tool_name not in self._available:
raise NoSuchToolError(
f"Unknown tool: {tool_name}. Available: {list(self._available.keys())}"
)
tool_class = self._available[tool_name]
with self._lock:
if tool_name not in self._available:
raise NoSuchToolError(
f"Unknown tool: {tool_name}. Available: {list(self._available.keys())}"
)
tool_class = self._available[tool_name]
self._instances[tool_name] = tool_class.from_config(
lambda: self.get_tool_config(tool_name)
)

View File

@@ -8,7 +8,7 @@ import tomli_w
from vibe.core.paths import (
AGENTS_MD_FILENAME,
TRUSTED_FOLDERS_FILE,
has_config_dirs_nearby,
walk_local_config_dirs,
)
@@ -16,12 +16,20 @@ def has_agents_md_file(path: Path) -> bool:
return (path / AGENTS_MD_FILENAME).exists()
def has_trustable_content(path: Path) -> bool:
if (path / ".vibe").exists():
return True
def find_trustable_files(path: Path) -> list[str]:
"""Return relative paths of files/dirs that would modify the agent's behavior."""
resolved = path.resolve()
found: list[str] = []
if has_agents_md_file(path):
return True
return has_config_dirs_nearby(path)
found.append(AGENTS_MD_FILENAME)
for config_dir in walk_local_config_dirs(path).config_dirs:
label = f"{config_dir.relative_to(resolved)}/"
if label not in found:
found.append(label)
return found
class TrustedFoldersManager:

View File

@@ -3,7 +3,8 @@ from __future__ import annotations
import base64
import os
import httpx
from mistralai.client import Mistral
from mistralai.client.models import SpeechOutputFormat
from vibe.core.config import TTSModelConfig, TTSProviderConfig
from vibe.core.tts.tts_client_port import TTSResult
@@ -11,34 +12,30 @@ from vibe.core.tts.tts_client_port import TTSResult
class MistralTTSClient:
def __init__(self, provider: TTSProviderConfig, model: TTSModelConfig) -> None:
self._api_key = os.getenv(provider.api_key_env_var, "")
self._server_url = provider.api_base
self._model_name = model.name
self._voice = model.voice
self._response_format = model.response_format
self._client = httpx.AsyncClient(
base_url=f"{provider.api_base}/v1",
headers={
"Authorization": f"Bearer {os.getenv(provider.api_key_env_var, '')}",
"Content-Type": "application/json",
},
timeout=60.0,
)
self._response_format: SpeechOutputFormat = model.response_format
self._client: Mistral | None = None
def _get_client(self) -> Mistral:
if self._client is None:
self._client = Mistral(api_key=self._api_key, server_url=self._server_url)
return self._client
async def speak(self, text: str) -> TTSResult:
response = await self._client.post(
"/audio/speech",
json={
"model": self._model_name,
"input": text,
"voice_id": self._voice,
"stream": False,
"response_format": self._response_format,
},
client = self._get_client()
response = await client.audio.speech.complete_async(
model=self._model_name,
input=text,
voice_id=self._voice,
response_format=self._response_format,
)
response.raise_for_status()
data = response.json()
audio_bytes = base64.b64decode(data["audio_data"])
audio_bytes = base64.b64decode(response.audio_data)
return TTSResult(audio_data=audio_bytes)
async def close(self) -> None:
await self._client.aclose()
if self._client is not None:
await self._client.__aexit__(exc_type=None, exc_val=None, exc_tb=None)
self._client = None

View File

@@ -487,7 +487,13 @@ class MessageList(Sequence[LLMMessage]):
hook()
def update_system_prompt(self, new: str) -> None:
"""Update the system prompt in place."""
"""Update the system prompt in place.
Called from a background thread during deferred init. A single
list-item assignment is atomic under CPython's GIL, and the
``_init_complete`` event ensures no ``act()`` call reads the
prompt concurrently, so no additional lock is needed here.
"""
self._data[0] = LLMMessage(role=Role.system, content=new)
@contextmanager

View File

@@ -1,6 +1,6 @@
"""Utilities package. Re-exports all public and test-used symbols from submodules.
Import read_safe/read_safe_async from vibe.core.utils.io and create_slug from
Import read_safe / read_safe_async / decode_safe (returns ReadSafeResult) from vibe.core.utils.io and create_slug from
vibe.core.utils.slug when needed to avoid circular imports with config.
"""

View File

@@ -1,29 +1,79 @@
from __future__ import annotations
from collections.abc import Iterator
import locale
from pathlib import Path
from typing import NamedTuple
import anyio
from charset_normalizer import from_bytes
def read_safe(path: Path, *, raise_on_error: bool = False) -> str:
"""Read a text file trying UTF-8 first, falling back to OS-default encoding.
class ReadSafeResult(NamedTuple):
"""Text decoded from a file and the codec name that successfully decoded it."""
On fallback, undecodable bytes are replaced with U+FFFD (REPLACEMENT CHARACTER).
When raise_on_error is True, decode errors propagate.
text: str
encoding: str
def _encodings_from_bom(raw: bytes) -> str | None:
if raw.startswith(b"\xef\xbb\xbf"):
return "utf-8-sig"
if raw.startswith(b"\xff\xfe\x00\x00"):
return "utf-32-le"
if raw.startswith(b"\x00\x00\xfe\xff"):
return "utf-32-be"
if raw.startswith(b"\xff\xfe"):
return "utf-16-le"
if raw.startswith(b"\xfe\xff"):
return "utf-16-be"
return None
def _encoding_from_best_match(raw: bytes) -> str | None:
if not (match := from_bytes(raw).best()):
return None
return match.encoding
def _get_candidate_encodings(raw: bytes) -> Iterator[str]:
"""Yield candidate encodings lazily — expensive detection runs only if needed."""
seen: set[str] = set()
yield "utf-8"
if (bom := _encodings_from_bom(raw)) and bom not in seen:
yield bom
if (
locale_encoding := locale.getpreferredencoding(False)
) and locale_encoding not in seen:
yield locale_encoding
if (best := _encoding_from_best_match(raw)) and best not in seen:
yield best
def decode_safe(raw: bytes, *, raise_on_error: bool = False) -> ReadSafeResult:
"""Decode ``raw`` like :func:`read_safe` after ``read_bytes``.
Tries UTF-8, locale, BOM, charset-normalizer, then UTF-8 (strict or replace).
``UnicodeDecodeError`` can only occur in that last step when
``raise_on_error`` is true.
"""
try:
return path.read_text(encoding="utf-8")
except (UnicodeDecodeError, ValueError):
if raise_on_error:
return path.read_text()
return path.read_text(errors="replace")
for encoding in _get_candidate_encodings(raw):
try:
return ReadSafeResult(raw.decode(encoding), encoding)
except (LookupError, UnicodeDecodeError, ValueError):
pass
errors = "strict" if raise_on_error else "replace"
return ReadSafeResult(raw.decode("utf-8", errors=errors), "utf-8")
async def read_safe_async(path: Path, *, raise_on_error: bool = False) -> str:
apath = anyio.Path(path)
try:
return await apath.read_text(encoding="utf-8")
except (UnicodeDecodeError, ValueError):
if raise_on_error:
return await apath.read_text()
return await apath.read_text(errors="replace")
def read_safe(path: Path, *, raise_on_error: bool = False) -> ReadSafeResult:
"""Read ``path`` and decode with :func:`decode_safe`."""
return decode_safe(path.read_bytes(), raise_on_error=raise_on_error)
async def read_safe_async(
path: Path, *, raise_on_error: bool = False
) -> ReadSafeResult:
"""Async :func:`read_safe` (``anyio``)."""
raw = await anyio.Path(path).read_bytes()
return decode_safe(raw, raise_on_error=raise_on_error)

View File

@@ -26,6 +26,15 @@ def run_onboarding(app: App | None = None) -> None:
case None:
rprint("\n[yellow]Setup cancelled. See you next time![/]")
sys.exit(0)
case str() as s if s.startswith("env_var_error:"):
env_key = s.removeprefix("env_var_error:")
rprint(
"\n[yellow]Could not save the API key because this provider is "
f"configured with an invalid environment variable name: {env_key}.[/]"
"\n[dim]The API key was not saved for this session. "
"Update the provider's `api_key_env_var` setting in your config and try again.[/]\n"
)
sys.exit(1)
case str() as s if s.startswith("save_error:"):
err = s.removeprefix("save_error:")
rprint(

View File

@@ -0,0 +1,208 @@
from __future__ import annotations
from dataclasses import dataclass
import os
import tomllib
from typing import Any
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
from vibe.core.config._settings import (
DEFAULT_ACTIVE_MODEL,
DEFAULT_MODELS,
DEFAULT_PROVIDERS,
)
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.logger import logger
_ONBOARDING_LIST_ADAPTER = TypeAdapter(list[Any])
def _default_provider_payloads() -> list[dict[str, Any]]:
return [provider.model_dump(mode="json") for provider in DEFAULT_PROVIDERS]
def _default_model_payloads() -> list[dict[str, Any]]:
return [model.model_dump(mode="json") for model in DEFAULT_MODELS]
class _OnboardingSnapshot(BaseModel):
active_model: str = DEFAULT_ACTIVE_MODEL
providers: list[Any] = Field(default_factory=_default_provider_payloads)
models: list[Any] = Field(default_factory=_default_model_payloads)
_ONBOARDING_FIELDS = frozenset(_OnboardingSnapshot.model_fields)
def _can_resolve_provider_from_explicit_overrides(
explicit_overrides: dict[str, Any],
) -> bool:
return "providers" in explicit_overrides
def _find_env_value(name: str) -> str | None:
expected_name = name.upper()
for env_name, value in os.environ.items():
if env_name.upper() == expected_name:
return value
return None
def _load_onboarding_toml_payload() -> dict[str, Any]:
try:
harness_files = get_harness_files_manager()
except RuntimeError:
return {}
config_file = harness_files.config_file
if config_file is None:
return {}
try:
with config_file.open("rb") as file:
toml_data = tomllib.load(file)
except FileNotFoundError:
return {}
except tomllib.TOMLDecodeError as err:
raise RuntimeError(f"Invalid TOML in {config_file}: {err}") from err
except OSError as err:
raise RuntimeError(f"Cannot read {config_file}: {err}") from err
return {
field_name: toml_data[field_name]
for field_name in _ONBOARDING_FIELDS
if field_name in toml_data
}
def _load_onboarding_env_payload_for_fields(
field_names: frozenset[str],
) -> dict[str, Any]:
payload: dict[str, Any] = {}
if (
"active_model" in field_names
and (active_model := _find_env_value("VIBE_ACTIVE_MODEL")) is not None
):
payload["active_model"] = active_model
if (
"providers" in field_names
and (providers := _find_env_value("VIBE_PROVIDERS")) is not None
):
payload["providers"] = _ONBOARDING_LIST_ADAPTER.validate_json(providers)
if (
"models" in field_names
and (models := _find_env_value("VIBE_MODELS")) is not None
):
payload["models"] = _ONBOARDING_LIST_ADAPTER.validate_json(models)
return payload
def _explicit_onboarding_overrides(**overrides: Any) -> dict[str, Any]:
return {
field_name: value
for field_name, value in overrides.items()
if field_name in _ONBOARDING_FIELDS
}
def _build_onboarding_snapshot_payload(**overrides: Any) -> dict[str, Any]:
explicit_overrides = _explicit_onboarding_overrides(**overrides)
payload = _OnboardingSnapshot().model_dump()
if explicit_overrides.keys() >= _ONBOARDING_FIELDS:
payload.update(explicit_overrides)
return payload
try:
payload.update(_load_onboarding_toml_payload())
except RuntimeError:
if not _can_resolve_provider_from_explicit_overrides(explicit_overrides):
raise
try:
payload.update(
_load_onboarding_env_payload_for_fields(
_ONBOARDING_FIELDS.difference(explicit_overrides)
)
)
except (ValidationError, ValueError):
if not _can_resolve_provider_from_explicit_overrides(explicit_overrides):
raise
payload.update(explicit_overrides)
return payload
def _validated_payloads[PayloadConfig: ModelConfig | ProviderConfig](
payloads: list[Any], model_type: type[PayloadConfig]
) -> list[PayloadConfig]:
validated_payloads: list[PayloadConfig] = []
for payload in payloads:
if isinstance(payload, model_type):
validated_payloads.append(payload)
continue
if not isinstance(payload, dict):
continue
try:
validated_payloads.append(model_type.model_validate(payload))
except (ValidationError, ValueError):
continue
return validated_payloads
def _resolve_provider(
*, active_model: str, snapshot: _OnboardingSnapshot
) -> ProviderConfig:
providers_by_name: dict[str, ProviderConfig] = {}
for provider in _validated_payloads(snapshot.providers, ProviderConfig):
providers_by_name.setdefault(provider.name, provider)
models = _validated_payloads(snapshot.models, ModelConfig)
for model_alias in (active_model, DEFAULT_ACTIVE_MODEL):
for model in models:
if model.alias != model_alias:
continue
if provider := providers_by_name.get(model.provider):
return provider
for model in models:
if provider := providers_by_name.get(model.provider):
return provider
if len(providers_by_name) == 1:
return next(iter(providers_by_name.values()))
return DEFAULT_PROVIDERS[0]
@dataclass(frozen=True)
class OnboardingContext:
provider: ProviderConfig
@property
def supports_browser_sign_in(self) -> bool:
return self.provider.supports_browser_sign_in
@classmethod
def from_config(cls, config: VibeConfig) -> OnboardingContext:
return cls(provider=config.get_provider_for_model(config.get_active_model()))
@classmethod
def load(cls, **overrides: Any) -> OnboardingContext:
try:
snapshot = _OnboardingSnapshot.model_validate(
_build_onboarding_snapshot_payload(**overrides)
)
return cls(
provider=_resolve_provider(
active_model=snapshot.active_model, snapshot=snapshot
)
)
except (RuntimeError, ValidationError, ValueError):
logger.warning(
"Onboarding config fallback activated; using defaults", exc_info=True
)
return cls.from_config(VibeConfig.model_construct())

View File

@@ -13,11 +13,12 @@ from textual.widgets import Input, Link, Static
from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import VibeConfig
from vibe.core.config import DEFAULT_PROVIDERS, ProviderConfig, VibeConfig
from vibe.core.paths import GLOBAL_ENV_FILE
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.types import Backend
from vibe.setup.onboarding.base import OnboardingScreen
from vibe.setup.onboarding.context import OnboardingContext
PROVIDER_HELP = {
"mistral": ("https://console.mistral.ai/codestral/cli", "Mistral AI Studio")
@@ -32,6 +33,42 @@ def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
set_key(GLOBAL_ENV_FILE.path, env_key, api_key)
def persist_api_key(provider: ProviderConfig, api_key: str) -> str:
env_key = provider.api_key_env_var
if not env_key:
return "env_var_error:<empty>"
try:
os.environ[env_key] = api_key
except ValueError:
return f"env_var_error:{env_key}"
try:
_save_api_key_to_env_file(env_key, api_key)
except (OSError, ValueError) as err:
return f"save_error:{err}"
if provider.backend == Backend.MISTRAL:
try:
telemetry = TelemetryClient(config_getter=VibeConfig)
telemetry.send_onboarding_api_key_added()
except Exception:
pass
return "completed"
def _get_mistral_provider() -> ProviderConfig:
return next(
provider for provider in DEFAULT_PROVIDERS if provider.name == "mistral"
)
def _resolve_onboarding_provider(
provider: ProviderConfig | None = None,
) -> ProviderConfig:
resolved_provider = provider or OnboardingContext.load().provider
if resolved_provider.api_key_env_var:
return resolved_provider
return _get_mistral_provider()
class ApiKeyScreen(OnboardingScreen):
BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+c", "cancel", "Cancel", show=False),
@@ -40,11 +77,9 @@ class ApiKeyScreen(OnboardingScreen):
NEXT_SCREEN = None
def __init__(self) -> None:
def __init__(self, provider: ProviderConfig | None = None) -> None:
super().__init__()
config = VibeConfig.model_construct()
active_model = config.get_active_model()
self.provider = config.get_provider_for_model(active_model)
self.provider = _resolve_onboarding_provider(provider)
def _compose_provider_link(self, provider_name: str) -> ComposeResult:
if self.provider.name not in PROVIDER_HELP:
@@ -124,20 +159,7 @@ class ApiKeyScreen(OnboardingScreen):
self._save_and_finish(event.value)
def _save_and_finish(self, api_key: str) -> None:
env_key = self.provider.api_key_env_var
os.environ[env_key] = api_key
try:
_save_api_key_to_env_file(env_key, api_key)
except OSError as err:
self.app.exit(f"save_error:{err}")
return
if self.provider.backend == Backend.MISTRAL:
try:
telemetry = TelemetryClient(config_getter=VibeConfig)
telemetry.send_onboarding_api_key_added()
except Exception:
pass # Telemetry is fire-and-forget; don't fail onboarding
self.app.exit("completed")
self.app.exit(persist_api_key(self.provider, api_key))
def on_mouse_up(self, event: MouseUp) -> None:
copy_selection_to_clipboard(self.app)

View File

@@ -6,7 +6,7 @@ from typing import Any, ClassVar
from textual import events
from textual.app import App, ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import CenterMiddle, Horizontal
from textual.containers import Center, CenterMiddle, Horizontal, VerticalScroll
from textual.message import Message
from textual.widgets import Static
@@ -38,46 +38,77 @@ class TrustFolderDialog(CenterMiddle):
class Untrusted(Message):
pass
def __init__(self, folder_path: Path, **kwargs: Any) -> None:
def __init__(
self, folder_path: Path, detected_files: list[str], **kwargs: Any
) -> None:
super().__init__(**kwargs)
self.folder_path = folder_path
self.detected_files = detected_files
self.selected_option = 0
self.option_widgets: list[Static] = []
def _compose_scroll_content(self) -> ComposeResult:
why_content = (
"Files here can modify AI behavior. Malicious "
"configs may exfiltrate data, run destructive "
"commands, or silently alter your code."
)
with Center(classes="trust-dialog-section-center"):
yield NoMarkupStatic(
why_content,
id="trust-dialog-warning",
classes="trust-dialog-section-content",
)
if self.detected_files:
yield NoMarkupStatic(
"Detected configuration files\n", classes="trust-dialog-section-header"
)
file_list = "\n".join(f"\u2022 {f}" for f in self.detected_files)
with Center(classes="trust-dialog-section-center"):
yield NoMarkupStatic(
file_list,
id="trust-dialog-files",
classes="trust-dialog-section-content",
)
def compose(self) -> ComposeResult:
with CenterMiddle(id="trust-dialog"):
yield NoMarkupStatic(
"Trust this folder and all its subfolders?", id="trust-dialog-title"
)
yield NoMarkupStatic(
str(self.folder_path),
id="trust-dialog-path",
classes="trust-dialog-path",
)
yield NoMarkupStatic(
"Files that can modify your Mistral Vibe setup were found here. Do you trust this folder and all its subfolders?",
id="trust-dialog-message",
classes="trust-dialog-message",
)
with CenterMiddle(id="trust-dialog-container"):
with CenterMiddle(id="trust-dialog"):
yield NoMarkupStatic("Trust this folder?", id="trust-dialog-title")
yield NoMarkupStatic(
str(self.folder_path),
id="trust-dialog-path",
classes="trust-dialog-path",
)
with Horizontal(id="trust-options-container"):
options = ["Yes", "No"]
for idx, text in enumerate(options):
widget = NoMarkupStatic(
f" {idx + 1}. {text}", classes="trust-option"
)
self.option_widgets.append(widget)
yield widget
with VerticalScroll(id="trust-dialog-content"):
yield from self._compose_scroll_content()
yield NoMarkupStatic(
"← → navigate Enter select", classes="trust-dialog-help"
)
yield NoMarkupStatic(
"Only trust folders you fully control",
id="trust-dialog-footer-warning",
classes="trust-dialog-footer-warning",
)
yield NoMarkupStatic(
f"Setting will be saved in: {TRUSTED_FOLDERS_FILE.path}",
id="trust-dialog-save-info",
classes="trust-dialog-save-info",
)
with Horizontal(id="trust-options-container"):
options = ["Yes, trust this folder", "No, ignore config files"]
for idx, text in enumerate(options):
widget = NoMarkupStatic(
f" {idx + 1}. {text}", classes="trust-option"
)
self.option_widgets.append(widget)
yield widget
yield NoMarkupStatic(
"← → navigate Enter select", classes="trust-dialog-help"
)
yield NoMarkupStatic(
f"Setting will be saved in: {TRUSTED_FOLDERS_FILE.path}",
id="trust-dialog-save-info",
classes="trust-dialog-save-info",
)
async def on_mount(self) -> None:
self.selected_option = 1 # Default to "No"
@@ -85,7 +116,7 @@ class TrustFolderDialog(CenterMiddle):
self.focus()
def _update_options(self) -> None:
options = ["Yes", "No"]
options = ["Yes, trust this folder", "No, ignore config files"]
if len(self.option_widgets) != len(options):
return
@@ -146,9 +177,12 @@ class TrustFolderApp(App):
Binding("ctrl+c", "quit_without_saving", "Quit", show=False, priority=True),
]
def __init__(self, folder_path: Path, **kwargs: Any) -> None:
def __init__(
self, folder_path: Path, detected_files: list[str], **kwargs: Any
) -> None:
super().__init__(**kwargs)
self.folder_path = folder_path
self.detected_files = detected_files
self._result: bool | None = None
self._quit_without_saving = False
@@ -156,7 +190,7 @@ class TrustFolderApp(App):
self.theme = "textual-ansi"
def compose(self) -> ComposeResult:
yield TrustFolderDialog(self.folder_path)
yield TrustFolderDialog(self.folder_path, self.detected_files)
def action_quit_without_saving(self) -> None:
self._quit_without_saving = True
@@ -177,6 +211,6 @@ class TrustFolderApp(App):
return self._result
def ask_trust_folder(folder_path: Path) -> bool | None:
app = TrustFolderApp(folder_path)
def ask_trust_folder(folder_path: Path, detected_files: list[str]) -> bool | None:
app = TrustFolderApp(folder_path, detected_files)
return app.run_trust_dialog()

View File

@@ -3,14 +3,25 @@ Screen {
background: transparent 80%;
}
#trust-dialog {
width: 70;
max-width: 90%;
min-width: 50;
height: auto;
max-width: 70;
overflow-y: scroll;
border: round ansi_bright_black;
background: transparent;
height:auto;
max-height: 1fr;
padding: 1;
padding-left:5;
padding-right:5;
scrollbar-size: 1 1;
}
#trust-dialog-content {
width: 100%;
height: auto;
min-height: 3;
max-height: 10;
}
#trust-dialog-title {
@@ -31,15 +42,42 @@ Screen {
margin-bottom: 1;
}
#trust-dialog-message {
.trust-dialog-section-header {
width: 100%;
height: auto;
color: ansi_default;
text-align: center;
text-wrap: wrap;
padding: 0 2;
}
.trust-dialog-section-center {
width: 100%;
height: auto;
margin-bottom: 1;
}
.trust-dialog-section-content {
width: auto;
max-width: 100%;
height: auto;
color: ansi_default;
text-align: center;
text-overflow: fold;
}
.trust-dialog-footer-warning {
width: 100%;
height: auto;
color: ansi_yellow;
text-align: center;
text-style: bold;
border-top: solid ansi_bright_black;
border-bottom: solid ansi_bright_black;
margin-bottom: 1;
padding: 0 1;
}
#trust-options-container {
width: 100%;
height: auto;
@@ -79,5 +117,4 @@ Screen {
text-align: center;
text-style: italic;
text-wrap: wrap;
margin-bottom: 1;
}

View File

@@ -1,4 +1,4 @@
# What's new in v2.7.4
- **MCP command**: New `/mcp` command to display MCP servers and their status
- **Manual command output**: Manual `!` commands now forward their output to the agent as context
- **Console view**: New `ctrl+\` keybind to toggle logs display.
# What's new in v2.7.5
- **Trust dialog**: Display detected files and LLM risks in the trust folder dialog
- **Faster startup**: Deferred MCP and git I/O to a background thread for faster CLI startup