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
|
|
@ -28,7 +28,7 @@ class TestACPInitialize:
|
|||
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.4"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.5"
|
||||
)
|
||||
|
||||
assert response.auth_methods == []
|
||||
|
|
@ -52,7 +52,7 @@ class TestACPInitialize:
|
|||
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.4"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.5"
|
||||
)
|
||||
|
||||
assert response.auth_methods is not None
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class TestEdit:
|
|||
def test_returns_modified_content(self) -> None:
|
||||
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
with patch("pathlib.Path.read_text", return_value="modified"):
|
||||
with patch("pathlib.Path.read_bytes", return_value=b"modified"):
|
||||
with patch("pathlib.Path.unlink"):
|
||||
editor = ExternalEditor()
|
||||
result = editor.edit("original")
|
||||
|
|
@ -35,7 +35,7 @@ class TestEdit:
|
|||
def test_returns_none_when_content_unchanged(self) -> None:
|
||||
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
|
||||
with patch("subprocess.run"):
|
||||
with patch("pathlib.Path.read_text", return_value="same"):
|
||||
with patch("pathlib.Path.read_bytes", return_value=b"same"):
|
||||
with patch("pathlib.Path.unlink"):
|
||||
editor = ExternalEditor()
|
||||
result = editor.edit("same")
|
||||
|
|
@ -44,7 +44,7 @@ class TestEdit:
|
|||
def test_strips_trailing_whitespace(self) -> None:
|
||||
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
|
||||
with patch("subprocess.run"):
|
||||
with patch("pathlib.Path.read_text", return_value="content\n\n"):
|
||||
with patch("pathlib.Path.read_bytes", return_value=b"content\n\n"):
|
||||
with patch("pathlib.Path.unlink"):
|
||||
editor = ExternalEditor()
|
||||
result = editor.edit("original")
|
||||
|
|
@ -53,7 +53,7 @@ class TestEdit:
|
|||
def test_handles_editor_with_args(self) -> None:
|
||||
with patch.dict("os.environ", {"VISUAL": "code --wait"}, clear=True):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
with patch("pathlib.Path.read_text", return_value="edited"):
|
||||
with patch("pathlib.Path.read_bytes", return_value=b"edited"):
|
||||
with patch("pathlib.Path.unlink"):
|
||||
editor = ExternalEditor()
|
||||
editor.edit("original")
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ def get_base_config() -> dict[str, Any]:
|
|||
"name": "mistral",
|
||||
"api_base": "https://api.mistral.ai/v1",
|
||||
"api_key_env_var": "MISTRAL_API_KEY",
|
||||
"browser_auth_base_url": "https://console.mistral.ai",
|
||||
"browser_auth_api_base_url": "https://console.mistral.ai/api",
|
||||
"backend": "mistral",
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
0
tests/narrator_manager/__init__.py
Normal file
0
tests/narrator_manager/__init__.py
Normal file
192
tests/narrator_manager/test_narrator_manager.py
Normal file
192
tests/narrator_manager/test_narrator_manager.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from tests.stubs.fake_audio_player import FakeAudioPlayer
|
||||
from tests.stubs.fake_tts_client import FakeTTSClient
|
||||
from vibe.cli.narrator_manager import NarratorManager, NarratorState
|
||||
from vibe.cli.turn_summary import TurnSummaryResult
|
||||
from vibe.core.tts.tts_client_port import TTSResult
|
||||
|
||||
|
||||
def _make_manager(
|
||||
*,
|
||||
narrator_enabled: bool = True,
|
||||
telemetry_client: MagicMock | None = None,
|
||||
tts_client: FakeTTSClient | None = None,
|
||||
) -> tuple[NarratorManager, FakeAudioPlayer]:
|
||||
config = build_test_vibe_config(narrator_enabled=narrator_enabled)
|
||||
audio_player = FakeAudioPlayer()
|
||||
manager = NarratorManager(
|
||||
config_getter=lambda: config,
|
||||
audio_player=audio_player,
|
||||
telemetry_client=telemetry_client,
|
||||
)
|
||||
manager._tts_client = tts_client or FakeTTSClient(
|
||||
result=TTSResult(audio_data=b"fake-audio")
|
||||
)
|
||||
return manager, audio_player
|
||||
|
||||
|
||||
def _find_telemetry_calls(mock: MagicMock, event_name: str) -> list[dict[str, object]]:
|
||||
results: list[dict[str, object]] = []
|
||||
for call in mock.send_telemetry_event.call_args_list:
|
||||
if call[0][0] == event_name:
|
||||
results.append(call[0][1])
|
||||
return results
|
||||
|
||||
|
||||
class TestTelemetryTracking:
|
||||
@pytest.mark.asyncio
|
||||
async def test_requested_event_on_turn_end(self) -> None:
|
||||
mock_telemetry = MagicMock()
|
||||
manager, _ = _make_manager(telemetry_client=mock_telemetry)
|
||||
manager._turn_summary.start_turn("test")
|
||||
manager.on_turn_end()
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.requested")
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["trigger"] == "autoplay_next_message"
|
||||
assert isinstance(calls[0]["read_aloud_session_id"], str)
|
||||
assert len(calls[0]["read_aloud_session_id"]) == 36
|
||||
|
||||
def test_no_requested_event_when_narrator_disabled(self) -> None:
|
||||
mock_telemetry = MagicMock()
|
||||
manager, _ = _make_manager(
|
||||
narrator_enabled=False, telemetry_client=mock_telemetry
|
||||
)
|
||||
manager.on_turn_end()
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.requested")
|
||||
assert len(calls) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_play_started_on_speak(self) -> None:
|
||||
mock_telemetry = MagicMock()
|
||||
manager, _ = _make_manager(telemetry_client=mock_telemetry)
|
||||
|
||||
manager._turn_summary.start_turn("test")
|
||||
manager.on_turn_end()
|
||||
manager._on_turn_summary(
|
||||
TurnSummaryResult(
|
||||
summary="Test summary", generation=manager._turn_summary.generation
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.play_started")
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["speed_selection"] is None
|
||||
assert isinstance(calls[0]["time_to_first_read_s"], float)
|
||||
assert calls[0]["time_to_first_read_s"] >= 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ended_completed_on_playback_finished(self) -> None:
|
||||
mock_telemetry = MagicMock()
|
||||
manager, _ = _make_manager(telemetry_client=mock_telemetry)
|
||||
|
||||
manager._turn_summary.start_turn("test")
|
||||
manager.on_turn_end()
|
||||
manager._on_turn_summary(
|
||||
TurnSummaryResult(
|
||||
summary="Test summary", generation=manager._turn_summary.generation
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
assert manager.state == NarratorState.SPEAKING
|
||||
|
||||
manager._on_playback_finished()
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["status"] == "completed"
|
||||
assert calls[0]["error_type"] is None
|
||||
assert calls[0]["speed_selection"] is None
|
||||
assert isinstance(calls[0]["elapsed_seconds"], float)
|
||||
assert calls[0]["elapsed_seconds"] >= 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ended_error_on_tts_failure(self) -> None:
|
||||
mock_telemetry = MagicMock()
|
||||
|
||||
class FailingTTSClient:
|
||||
def __init__(self, *_args: object, **_kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
async def speak(self, text: str) -> TTSResult:
|
||||
raise RuntimeError("TTS failed")
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
manager, _ = _make_manager(telemetry_client=mock_telemetry)
|
||||
manager._tts_client = FailingTTSClient()
|
||||
|
||||
manager._turn_summary.start_turn("test")
|
||||
manager.on_turn_end()
|
||||
manager._on_turn_summary(
|
||||
TurnSummaryResult(
|
||||
summary="Test summary", generation=manager._turn_summary.generation
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["status"] == "error"
|
||||
assert calls[0]["error_type"] == "RuntimeError"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ended_canceled_on_cancel(self) -> None:
|
||||
mock_telemetry = MagicMock()
|
||||
manager, _ = _make_manager(telemetry_client=mock_telemetry)
|
||||
|
||||
manager._turn_summary.start_turn("test")
|
||||
manager.on_turn_end()
|
||||
assert manager.state == NarratorState.SUMMARIZING
|
||||
|
||||
manager.cancel()
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["status"] == "canceled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_during_speaking_fires_single_ended_event(self) -> None:
|
||||
mock_telemetry = MagicMock()
|
||||
manager, _ = _make_manager(telemetry_client=mock_telemetry)
|
||||
|
||||
manager._turn_summary.start_turn("test")
|
||||
manager.on_turn_end()
|
||||
manager._on_turn_summary(
|
||||
TurnSummaryResult(
|
||||
summary="Test summary", generation=manager._turn_summary.generation
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
assert manager.state == NarratorState.SPEAKING
|
||||
|
||||
manager.cancel()
|
||||
# Simulate the delayed callback that would come from call_soon_threadsafe
|
||||
manager._on_playback_finished()
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["status"] == "canceled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_error_without_telemetry_client(self) -> None:
|
||||
manager, _ = _make_manager()
|
||||
manager._turn_summary.start_turn("test")
|
||||
manager.on_turn_end()
|
||||
manager._on_turn_summary(
|
||||
TurnSummaryResult(
|
||||
summary="Test summary", generation=manager._turn_summary.generation
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
manager._on_playback_finished()
|
||||
61
tests/narrator_manager/test_telemetry.py
Normal file
61
tests/narrator_manager/test_telemetry.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.narrator_manager.telemetry import ReadAloudTrackingState
|
||||
|
||||
|
||||
class TestReadAloudTrackingState:
|
||||
def test_default_state(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
assert state.session_id == ""
|
||||
assert state.request_time == 0.0
|
||||
assert state.play_start_time == 0.0
|
||||
|
||||
def test_reset_generates_session_id(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
state.reset()
|
||||
assert state.session_id != ""
|
||||
assert len(state.session_id) == 36 # UUID format
|
||||
|
||||
def test_reset_generates_unique_session_ids(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
state.reset()
|
||||
first_id = state.session_id
|
||||
state.reset()
|
||||
assert state.session_id != first_id
|
||||
|
||||
def test_reset_clears_play_start_time(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
state.reset()
|
||||
state.mark_play_started()
|
||||
assert state.play_start_time > 0.0
|
||||
state.reset()
|
||||
assert state.play_start_time == 0.0
|
||||
|
||||
def test_mark_play_started(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
state.reset()
|
||||
state.mark_play_started()
|
||||
assert state.play_start_time > 0.0
|
||||
|
||||
def test_time_to_first_read_s(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
state.reset()
|
||||
state.mark_play_started()
|
||||
ttfr = state.time_to_first_read_s()
|
||||
assert ttfr >= 0.0
|
||||
|
||||
def test_time_to_first_read_s_before_play(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
state.reset()
|
||||
assert state.time_to_first_read_s() == 0.0
|
||||
|
||||
def test_elapsed_since_play_s(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
state.reset()
|
||||
state.mark_play_started()
|
||||
elapsed = state.elapsed_since_play_s()
|
||||
assert elapsed >= 0.0
|
||||
|
||||
def test_elapsed_since_play_s_before_play(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
assert state.elapsed_since_play_s() == 0.0
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import override
|
||||
|
||||
|
|
@ -25,7 +24,7 @@ def _exit_raiser(code: int = 0) -> None:
|
|||
|
||||
|
||||
def test_exits_on_cancel(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setattr(sys, "exit", _exit_raiser)
|
||||
|
||||
|
|
@ -38,7 +37,7 @@ def test_exits_on_cancel(
|
|||
|
||||
|
||||
def test_warns_on_save_error(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setattr(sys, "exit", _exit_raiser)
|
||||
|
||||
|
|
@ -49,8 +48,25 @@ def test_warns_on_save_error(
|
|||
assert "disk full" in out
|
||||
|
||||
|
||||
def test_exits_on_invalid_api_key_env_var(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setattr(sys, "exit", _exit_raiser)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
onboarding.run_onboarding(StubApp("env_var_error:BAD=NAME"))
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "Could not save the API key because this provider is configured" in out
|
||||
assert "invalid" in out
|
||||
assert "environment variable name: BAD=NAME" in out
|
||||
assert "was not saved for this session" in out
|
||||
assert "set for this session only" not in out
|
||||
|
||||
|
||||
def test_successfully_completes(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setattr(sys, "exit", _exit_raiser)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from textual.pilot import Pilot
|
||||
from textual.widgets import Input
|
||||
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE
|
||||
from vibe.setup.onboarding import OnboardingApp
|
||||
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
|
||||
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen, persist_api_key
|
||||
|
||||
|
||||
async def _wait_for(
|
||||
|
|
@ -55,3 +57,64 @@ async def test_ui_gets_through_the_onboarding_successfully() -> None:
|
|||
env_contents = GLOBAL_ENV_FILE.path.read_text(encoding="utf-8")
|
||||
assert "MISTRAL_API_KEY" in env_contents
|
||||
assert api_key_value in env_contents
|
||||
|
||||
|
||||
def test_api_key_screen_falls_back_to_mistral_for_provider_without_env_key() -> None:
|
||||
screen = ApiKeyScreen(
|
||||
provider=ProviderConfig(
|
||||
name="llamacpp", api_base="http://127.0.0.1:8080/v1", api_key_env_var=""
|
||||
)
|
||||
)
|
||||
|
||||
assert screen.provider.name == "mistral"
|
||||
assert screen.provider.api_key_env_var == "MISTRAL_API_KEY"
|
||||
|
||||
|
||||
def test_api_key_screen_keeps_provider_with_explicit_env_key() -> None:
|
||||
provider = ProviderConfig(
|
||||
name="custom",
|
||||
api_base="https://custom.example/v1",
|
||||
api_key_env_var="CUSTOM_API_KEY",
|
||||
)
|
||||
|
||||
screen = ApiKeyScreen(provider=provider)
|
||||
|
||||
assert screen.provider == provider
|
||||
|
||||
|
||||
def test_api_key_screen_uses_mistral_fallback_for_context_without_env_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"vibe.setup.onboarding.screens.api_key.OnboardingContext.load",
|
||||
lambda: SimpleNamespace(
|
||||
provider=ProviderConfig(
|
||||
name="llamacpp", api_base="http://127.0.0.1:8080/v1", api_key_env_var=""
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
screen = ApiKeyScreen()
|
||||
|
||||
assert screen.provider.name == "mistral"
|
||||
assert screen.provider.api_key_env_var == "MISTRAL_API_KEY"
|
||||
|
||||
|
||||
def test_persist_api_key_returns_save_error_for_invalid_env_var_name() -> None:
|
||||
provider = ProviderConfig(
|
||||
name="custom", api_base="https://custom.example/v1", api_key_env_var="BAD=NAME"
|
||||
)
|
||||
|
||||
result = persist_api_key(provider, "secret")
|
||||
|
||||
assert result == "env_var_error:BAD=NAME"
|
||||
|
||||
|
||||
def test_persist_api_key_returns_env_var_error_for_empty_env_var_name() -> None:
|
||||
provider = ProviderConfig(
|
||||
name="custom", api_base="https://custom.example/v1", api_key_env_var=""
|
||||
)
|
||||
|
||||
result = persist_api_key(provider, "secret")
|
||||
|
||||
assert result == "env_var_error:<empty>"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import pytest
|
|||
|
||||
from vibe.core.config import SessionLoggingConfig
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.types import LLMMessage, Role, ToolCall
|
||||
from vibe.core.types import LLMMessage, Role, SessionMetadata, ToolCall
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -1008,7 +1009,7 @@ class TestSessionLoaderUTF8Encoding:
|
|||
session_folder = session_dir / "test_20230101_120000_latin100"
|
||||
session_folder.mkdir()
|
||||
|
||||
# \x81 invalid UTF-8 and undefined in CP1252 → U+FFFD on all platforms
|
||||
# Path contains U+0081; file written as Latin-1. Decoding matches read_safe.
|
||||
metadata_content = {
|
||||
"session_id": "latin1-test",
|
||||
"start_time": "2023-01-01T12:00:00Z",
|
||||
|
|
@ -1026,9 +1027,10 @@ class TestSessionLoaderUTF8Encoding:
|
|||
messages_file = session_folder / "messages.jsonl"
|
||||
messages_file.write_text('{"role": "user", "content": "Hello"}\n')
|
||||
|
||||
expected = SessionMetadata.model_validate_json(read_safe(metadata_file).text)
|
||||
metadata = SessionLoader.load_metadata(session_folder)
|
||||
assert metadata.session_id == "latin1-test"
|
||||
assert metadata.environment["working_directory"] == "/home/user/caf<61>_project"
|
||||
assert metadata == expected
|
||||
|
||||
def test_load_session_with_utf8_metadata_and_messages(
|
||||
self, session_config: SessionLoggingConfig
|
||||
|
|
|
|||
|
|
@ -78,9 +78,9 @@
|
|||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
<rect fill="#121212" x="0" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="1.5" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="573.4" y="1.5" width="402.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<rect fill="#121212" x="0" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="1.5" width="805.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="829.6" y="1.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">✓</text><text class="terminal-r3" x="24.4" y="20" textLength="549" clip-path="url(#terminal-line-0)">Teleported to Nuage: https://chat.example.com</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">✓</text><text class="terminal-r3" x="24.4" y="20" textLength="805.2" clip-path="url(#terminal-line-0)">Teleported to a new async coding session: https://chat.example.com</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
|
|
@ -180,9 +180,9 @@
|
|||
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="483.6" textLength="183" clip-path="url(#terminal-line-19)">devstral-latest</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="414.8" clip-path="url(#terminal-line-20)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type </text><text class="terminal-r3" x="231.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">/help</text><text class="terminal-r1" x="292.8" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)"> for more information</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)"> v0.0.0 · </text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="61" clip-path="url(#terminal-line-20)">Type </text><text class="terminal-r3" x="231.8" y="508" textLength="61" clip-path="url(#terminal-line-20)">/help</text><text class="terminal-r1" x="292.8" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> for more information</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">┃</text><text class="terminal-r5" x="24.4" y="581.2" textLength="122" clip-path="url(#terminal-line-23)">What's New</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">┃</text><text class="terminal-r1" x="24.4" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">• Feature 1</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
138
tests/test_deferred_init.py
Normal file
138
tests/test_deferred_init.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""Tests for deferred initialization: _complete_init, wait_for_init, integrate_mcp idempotency."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_agent_loop, build_test_vibe_config
|
||||
from tests.stubs.fake_mcp_registry import FakeMCPRegistry
|
||||
from vibe.core.config import MCPStdio
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _complete_init
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCompleteInit:
|
||||
def test_success_sets_init_complete(self) -> None:
|
||||
loop = build_test_agent_loop(defer_heavy_init=True)
|
||||
assert not loop.is_initialized
|
||||
|
||||
loop._complete_init()
|
||||
|
||||
assert loop.is_initialized
|
||||
assert loop._init_error is None
|
||||
|
||||
def test_failure_sets_init_complete_and_stores_error(self) -> None:
|
||||
loop = build_test_agent_loop(defer_heavy_init=True)
|
||||
error = RuntimeError("mcp boom")
|
||||
|
||||
with patch.object(loop.tool_manager, "integrate_mcp", side_effect=error):
|
||||
loop._complete_init()
|
||||
|
||||
assert loop.is_initialized
|
||||
assert loop._init_error is error
|
||||
|
||||
def test_mcp_integration_internal_failure_sets_init_error(self) -> None:
|
||||
mcp_server = MCPStdio(name="test-server", transport="stdio", command="echo")
|
||||
config = build_test_vibe_config(mcp_servers=[mcp_server])
|
||||
loop = build_test_agent_loop(config=config, defer_heavy_init=True)
|
||||
|
||||
with patch.object(
|
||||
loop.tool_manager._mcp_registry,
|
||||
"get_tools",
|
||||
side_effect=RuntimeError("mcp discovery boom"),
|
||||
):
|
||||
loop._complete_init()
|
||||
|
||||
assert loop.is_initialized
|
||||
assert isinstance(loop._init_error, RuntimeError)
|
||||
assert str(loop._init_error) == "mcp discovery boom"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wait_for_init
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWaitForInit:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_immediately_when_already_complete(self) -> None:
|
||||
loop = build_test_agent_loop(defer_heavy_init=True)
|
||||
loop._complete_init()
|
||||
|
||||
await loop.wait_for_init() # should not block
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waits_for_background_thread(self) -> None:
|
||||
loop = build_test_agent_loop(defer_heavy_init=True)
|
||||
|
||||
thread = threading.Thread(target=loop._complete_init, daemon=True)
|
||||
thread.start()
|
||||
|
||||
await loop.wait_for_init()
|
||||
thread.join(timeout=1)
|
||||
|
||||
assert loop.is_initialized
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_stored_error(self) -> None:
|
||||
loop = build_test_agent_loop(defer_heavy_init=True)
|
||||
error = RuntimeError("init failed")
|
||||
|
||||
with patch.object(loop.tool_manager, "integrate_mcp", side_effect=error):
|
||||
loop._complete_init()
|
||||
|
||||
with pytest.raises(RuntimeError, match="init failed"):
|
||||
await loop.wait_for_init()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_error_for_every_caller(self) -> None:
|
||||
loop = build_test_agent_loop(defer_heavy_init=True)
|
||||
error = RuntimeError("once only")
|
||||
|
||||
with patch.object(loop.tool_manager, "integrate_mcp", side_effect=error):
|
||||
loop._complete_init()
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await loop.wait_for_init()
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await loop.wait_for_init()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# integrate_mcp idempotency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIntegrateMcpIdempotency:
|
||||
def test_second_call_is_noop(self) -> None:
|
||||
mcp_server = MCPStdio(name="test-server", transport="stdio", command="echo")
|
||||
config = build_test_vibe_config(mcp_servers=[mcp_server])
|
||||
registry = FakeMCPRegistry()
|
||||
manager = ToolManager(lambda: config, mcp_registry=registry, defer_mcp=True)
|
||||
|
||||
manager.integrate_mcp()
|
||||
tools_after_first = dict(manager.registered_tools)
|
||||
|
||||
# Spy on the registry to ensure get_tools is not called again.
|
||||
registry.get_tools = MagicMock(wraps=registry.get_tools)
|
||||
manager.integrate_mcp()
|
||||
|
||||
registry.get_tools.assert_not_called()
|
||||
assert manager.registered_tools == tools_after_first
|
||||
|
||||
def test_flag_not_set_when_no_servers(self) -> None:
|
||||
config = build_test_vibe_config(mcp_servers=[])
|
||||
manager = ToolManager(lambda: config, defer_mcp=True)
|
||||
|
||||
manager.integrate_mcp()
|
||||
|
||||
# No servers means the method returns early without setting the flag,
|
||||
# so a future call with servers would still run discovery.
|
||||
assert not manager._mcp_integrated
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from vibe.core.config import TTSModelConfig, TTSProviderConfig
|
||||
|
|
@ -27,13 +27,20 @@ def _make_model() -> TTSModelConfig:
|
|||
|
||||
|
||||
class TestMistralTTSClientInit:
|
||||
def test_client_configured_with_base_url_and_auth(
|
||||
def test_lazy_client_creation(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
client = MistralTTSClient(_make_provider(), _make_model())
|
||||
assert client._client is None
|
||||
|
||||
def test_get_client_creates_mistral_instance(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
client = MistralTTSClient(_make_provider(), _make_model())
|
||||
assert str(client._client.base_url) == "https://api.mistral.ai/v1/"
|
||||
assert client._client.headers["authorization"] == "Bearer test-key"
|
||||
sdk_client = client._get_client()
|
||||
assert sdk_client is not None
|
||||
assert client._client is sdk_client
|
||||
assert client._get_client() is sdk_client
|
||||
|
||||
|
||||
class TestMistralTTSClient:
|
||||
|
|
@ -46,55 +53,62 @@ class TestMistralTTSClient:
|
|||
raw_audio = b"fake-audio-data-for-testing"
|
||||
encoded_audio = base64.b64encode(raw_audio).decode()
|
||||
|
||||
async def mock_post(self_client, url, **kwargs):
|
||||
assert url == "/audio/speech"
|
||||
body = kwargs["json"]
|
||||
assert body["model"] == "voxtral-mini-tts-latest"
|
||||
assert body["input"] == "Hello"
|
||||
assert body["voice_id"] == "gb_jane_neutral"
|
||||
assert body["stream"] is False
|
||||
assert body["response_format"] == "wav"
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
json={"audio_data": encoded_audio},
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
mock_response = MagicMock()
|
||||
mock_response.audio_data = encoded_audio
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
|
||||
mock_complete_async = AsyncMock(return_value=mock_response)
|
||||
|
||||
client = MistralTTSClient(_make_provider(), _make_model())
|
||||
result = await client.speak("Hello")
|
||||
|
||||
with patch.object(
|
||||
type(client._get_client().audio.speech),
|
||||
"complete_async",
|
||||
mock_complete_async,
|
||||
):
|
||||
result = await client.speak("Hello")
|
||||
|
||||
assert isinstance(result, TTSResult)
|
||||
assert result.audio_data == raw_audio
|
||||
await client.close()
|
||||
mock_complete_async.assert_called_once_with(
|
||||
model="voxtral-mini-tts-latest",
|
||||
input="Hello",
|
||||
voice_id="gb_jane_neutral",
|
||||
response_format="wav",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_speak_raises_on_http_error(
|
||||
async def test_speak_raises_on_sdk_error(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
import httpx
|
||||
from mistralai.client.errors import SDKError
|
||||
|
||||
async def mock_post(self_client, url, **kwargs):
|
||||
return httpx.Response(
|
||||
status_code=500,
|
||||
json={"error": "Internal Server Error"},
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
|
||||
fake_response = httpx.Response(
|
||||
status_code=500,
|
||||
request=httpx.Request("POST", "https://api.mistral.ai/v1/audio/speech"),
|
||||
)
|
||||
mock_complete_async = AsyncMock(
|
||||
side_effect=SDKError("API error", fake_response)
|
||||
)
|
||||
|
||||
client = MistralTTSClient(_make_provider(), _make_model())
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
type(client._get_client().audio.speech),
|
||||
"complete_async",
|
||||
mock_complete_async,
|
||||
),
|
||||
pytest.raises(SDKError),
|
||||
):
|
||||
await client.speak("Hello")
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_closes_underlying_client(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def test_close_resets_client(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
|
||||
client = MistralTTSClient(_make_provider(), _make_model())
|
||||
client._get_client()
|
||||
assert client._client is not None
|
||||
await client.close()
|
||||
assert client._client.is_closed
|
||||
assert client._client is None
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ def test_load_whats_new_content_handles_os_error(tmp_path: Path) -> None:
|
|||
whats_new_file.write_text("content")
|
||||
|
||||
with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
|
||||
with patch.object(Path, "read_text", side_effect=OSError("Permission denied")):
|
||||
with patch.object(Path, "read_bytes", side_effect=OSError("Permission denied")):
|
||||
result = load_whats_new_content()
|
||||
|
||||
assert result is None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue