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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 2830 additions and 594 deletions

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

Before After
Before After

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

Before After
Before After

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