v2.7.5 (#589)
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:
parent
e9a9217cc8
commit
e1a25caa52
85 changed files with 2830 additions and 594 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
34
tests/core/test_search_replace_encoding.py
Normal file
34
tests/core/test_search_replace_encoding.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue