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

@ -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: