diff --git a/.gitignore b/.gitignore index 1eef366..096b9ac 100644 --- a/.gitignore +++ b/.gitignore @@ -200,5 +200,6 @@ result-* tests/playground/* . -# Profiler HTML reports (generated by vibe.cli.profiler) +# Profiler HTML/TXT reports (generated by vibe.cli.profiler) *-profile.html +*-profile.txt diff --git a/.vscode/launch.json b/.vscode/launch.json index 2b05eb1..186480e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,5 +1,5 @@ { - "version": "2.7.4", + "version": "2.7.5", "configurations": [ { "name": "ACP Server", diff --git a/AGENTS.md b/AGENTS.md index f27b9b1..80ad6d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,6 +134,20 @@ guidelines: - uv run script.py to run a script within the uv environment - uv run pytest (or any other python tool) to run the tool within the uv environment + - title: "Safe File Reading" + description: > + When reading files from disk, prefer the helpers in `vibe.core.utils.io` over raw + `Path.read_text()`, `Path.read_bytes().decode()`, or `open()` calls: + - `read_safe(path)` — synchronous read with automatic encoding detection. + - `read_safe_async(path)` — async equivalent (anyio-based). + - `decode_safe(raw)` — decode an already-read `bytes` object. + These functions try UTF-8 first, then BOM detection, the locale encoding, and + `charset_normalizer` (lazily, only when cheaper candidates fail). They return a + `ReadSafeResult(text, encoding)` so callers always get valid `str` output without + having to handle encoding errors manually. + Use `raise_on_error=True` only when the caller must distinguish corrupt files from + valid ones; the default (`False`) replaces undecodable bytes with U+FFFD. + - title: "Imports in Cursor (no Pylance)" description: > Cursor's built-in Pyright does not offer the "Add import" quick fix (Ctrl+.). To add a missing import: diff --git a/CHANGELOG.md b/CHANGELOG.md index d4796cf..65eaabd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.7.5] - 2026-04-14 + +### Changed + +- Display detected files and LLM risks in trust folder dialog +- Text-to-speech via the Mistral SDK with telemetry tracking +- Deferred MCP and git I/O to background thread for faster CLI startup +- Made telemetry URL configurable +- Bumped Textual to 8.2.1 + +### Fixed + +- Encoding detection fallback in `read_safe` for non-UTF-8 files +- Config saving logic cleanup + + ## [2.7.4] - 2026-04-09 ### Added diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index 02cd1a6..c22ebb7 100644 --- a/distribution/zed/extension.toml +++ b/distribution/zed/extension.toml @@ -1,7 +1,7 @@ id = "mistral-vibe" name = "Mistral Vibe" description = "Mistral's open-source coding assistant" -version = "2.7.4" +version = "2.7.5" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,25 +11,25 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-darwin-aarch64-2.7.4.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-darwin-aarch64-2.7.5.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-darwin-x86_64-2.7.4.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-darwin-x86_64-2.7.5.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-linux-aarch64-2.7.4.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-linux-aarch64-2.7.5.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-linux-x86_64-2.7.4.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-linux-x86_64-2.7.5.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-windows-aarch64-2.7.4.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-windows-aarch64-2.7.5.zip" cmd = "./vibe-acp.exe" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-windows-x86_64-2.7.4.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-windows-x86_64-2.7.5.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index e72ba55..a0c3733 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.7.4" +version = "2.7.5" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" @@ -30,11 +30,13 @@ dependencies = [ "agent-client-protocol==0.9.0", "anyio>=4.12.0", "cachetools>=5.5.0", + "charset-normalizer>=3.4.4", "cryptography>=44.0.0,<=46.0.3", "gitpython>=3.1.46", "giturlparse>=0.14.0", "google-auth>=2.0.0", "httpx>=0.28.1", + "jsonpatch>=1.33", "keyring>=25.6.0", "markdownify>=1.2.2", "mcp>=1.14.0", @@ -53,7 +55,7 @@ dependencies = [ "requests>=2.20.0", "rich>=14.0.0", "sounddevice>=0.5.1", - "textual>=8.1.1", + "textual>=8.2.1", "textual-speedups>=0.2.1", "tomli-w>=1.2.0", "tree-sitter>=0.25.2", @@ -171,10 +173,12 @@ order-by-type = true required-imports = ["from __future__ import annotations"] [tool.ruff.lint.pylint] +max-public-methods = 25 +max-positional-args = 10 max-statements = 50 max-branches = 15 max-locals = 15 -max-args = 9 +max-args = 10 max-returns = 6 max-nested-blocks = 5 diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index e4de77f..e41a0b1 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -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 diff --git a/tests/cli/test_external_editor.py b/tests/cli/test_external_editor.py index 0194406..c527617 100644 --- a/tests/cli/test_external_editor.py +++ b/tests/cli/test_external_editor.py @@ -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") diff --git a/tests/conftest.py b/tests/conftest.py index 1378b30..e2c0437 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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", } ], diff --git a/tests/core/test_config_resolution.py b/tests/core/test_config_resolution.py index 0a37568..50646ee 100644 --- a/tests/core/test_config_resolution.py +++ b/tests/core/test_config_resolution.py @@ -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 diff --git a/tests/core/test_local_config_walk.py b/tests/core/test_local_config_walk.py index 5560e90..6e3cc62 100644 --- a/tests/core/test_local_config_walk.py +++ b/tests/core/test_local_config_walk.py @@ -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 diff --git a/tests/core/test_remote_agent_loop.py b/tests/core/test_remote_agent_loop.py index 1d5e1cf..6e5af8e 100644 --- a/tests/core/test_remote_agent_loop.py +++ b/tests/core/test_remote_agent_loop.py @@ -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 diff --git a/tests/core/test_search_replace_encoding.py b/tests/core/test_search_replace_encoding.py new file mode 100644 index 0000000..82d8fb6 --- /dev/null +++ b/tests/core/test_search_replace_encoding.py @@ -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" diff --git a/tests/core/test_telemetry_send.py b/tests/core/test_telemetry_send.py index 1d07501..1015e91 100644 --- a/tests/core/test_telemetry_send.py +++ b/tests/core/test_telemetry_send.py @@ -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 diff --git a/tests/core/test_teleport_git.py b/tests/core/test_teleport_git.py index 5d0c283..1643e4e 100644 --- a/tests/core/test_teleport_git.py +++ b/tests/core/test_teleport_git.py @@ -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() diff --git a/tests/core/test_teleport_service.py b/tests/core/test_teleport_service.py index c5a3df5..6323a53 100644 --- a/tests/core/test_teleport_service.py +++ b/tests/core/test_teleport_service.py @@ -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() diff --git a/tests/core/test_trusted_folders.py b/tests/core/test_trusted_folders.py index f20e7ae..cfba99d 100644 --- a/tests/core/test_trusted_folders.py +++ b/tests/core/test_trusted_folders.py @@ -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 diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index dedb8d4..6904da2 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -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�\n" + assert result.text == "maf�\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�\n" + monkeypatch.setattr(io_utils, "_encoding_from_best_match", lambda _raw: None) + assert read_safe(f, raise_on_error=False).text == "maf�\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�\n" + with pytest.raises(UnicodeDecodeError): + await read_safe_async(f, raise_on_error=True) diff --git a/tests/core/tools/builtins/test_read_file.py b/tests/core/tools/builtins/test_read_file.py index 7f9d24e..200865e 100644 --- a/tests/core/tools/builtins/test_read_file.py +++ b/tests/core/tools/builtins/test_read_file.py @@ -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: diff --git a/tests/narrator_manager/__init__.py b/tests/narrator_manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/narrator_manager/test_narrator_manager.py b/tests/narrator_manager/test_narrator_manager.py new file mode 100644 index 0000000..bf5d189 --- /dev/null +++ b/tests/narrator_manager/test_narrator_manager.py @@ -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() diff --git a/tests/narrator_manager/test_telemetry.py b/tests/narrator_manager/test_telemetry.py new file mode 100644 index 0000000..744bc48 --- /dev/null +++ b/tests/narrator_manager/test_telemetry.py @@ -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 diff --git a/tests/onboarding/test_run_onboarding.py b/tests/onboarding/test_run_onboarding.py index 52b6fa2..785b86a 100644 --- a/tests/onboarding/test_run_onboarding.py +++ b/tests/onboarding/test_run_onboarding.py @@ -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) diff --git a/tests/onboarding/test_ui_onboarding.py b/tests/onboarding/test_ui_onboarding.py index e12201d..5883a48 100644 --- a/tests/onboarding/test_ui_onboarding.py +++ b/tests/onboarding/test_ui_onboarding.py @@ -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:" diff --git a/tests/session/test_session_loader.py b/tests/session/test_session_loader.py index beb4017..dfa1ce2 100644 --- a/tests/session/test_session_loader.py +++ b/tests/session/test_session_loader.py @@ -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�_project" + assert metadata == expected def test_load_session_with_utf8_metadata_and_messages( self, session_config: SessionLoggingConfig diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_complete.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_complete.svg index d46b637..b61aed2 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_complete.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_complete.svg @@ -78,9 +78,9 @@ - + - Teleported to Nuage: https://chat.example.com + Teleported to a new async coding session: https://chat.example.com diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg index 57a1d1e..6bd7a48 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg @@ -180,9 +180,9 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 ·  + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣Type /help for more information +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆ What's New • Feature 1 diff --git a/tests/test_deferred_init.py b/tests/test_deferred_init.py new file mode 100644 index 0000000..e3cc4a9 --- /dev/null +++ b/tests/test_deferred_init.py @@ -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 diff --git a/tests/tts/test_tts_client.py b/tests/tts/test_tts_client.py index 6cdff4f..0fa3335 100644 --- a/tests/tts/test_tts_client.py +++ b/tests/tts/test_tts_client.py @@ -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 diff --git a/tests/update_notifier/test_whats_new.py b/tests/update_notifier/test_whats_new.py index a2d75c7..98c43b5 100644 --- a/tests/update_notifier/test_whats_new.py +++ b/tests/update_notifier/test_whats_new.py @@ -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 diff --git a/uv.lock b/uv.lock index 05922c9..f3af77e 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.12" [options] -exclude-newer = "2026-04-02T14:58:56.644057Z" +exclude-newer = "2026-04-06T21:12:11.186502Z" exclude-newer-span = "P7D" [options.exclude-newer-package] @@ -569,6 +569,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + [[package]] name = "jsonpath-python" version = "1.1.5" @@ -578,6 +590,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/50/1a313fb700526b134c71eb8a225d8b83be0385dbb0204337b4379c698cef/jsonpath_python-1.1.5-py3-none-any.whl", hash = "sha256:a60315404d70a65e76c9a782c84e50600480221d94a58af47b7b4d437351cb4b", size = 14090, upload-time = "2026-03-17T06:16:39.152Z" }, ] +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -787,17 +808,19 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.7.4" +version = "2.7.5" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, { name = "anyio" }, { name = "cachetools" }, + { name = "charset-normalizer" }, { name = "cryptography" }, { name = "gitpython" }, { name = "giturlparse" }, { name = "google-auth" }, { name = "httpx" }, + { name = "jsonpatch" }, { name = "keyring" }, { name = "markdownify" }, { name = "mcp" }, @@ -853,11 +876,13 @@ requires-dist = [ { name = "agent-client-protocol", specifier = "==0.9.0" }, { name = "anyio", specifier = ">=4.12.0" }, { name = "cachetools", specifier = ">=5.5.0" }, + { name = "charset-normalizer", specifier = ">=3.4.4" }, { name = "cryptography", specifier = ">=44.0.0,<=46.0.3" }, { name = "gitpython", specifier = ">=3.1.46" }, { name = "giturlparse", specifier = ">=0.14.0" }, { name = "google-auth", specifier = ">=2.0.0" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "jsonpatch", specifier = ">=1.33" }, { name = "keyring", specifier = ">=25.6.0" }, { name = "markdownify", specifier = ">=1.2.2" }, { name = "mcp", specifier = ">=1.14.0" }, @@ -876,7 +901,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.20.0" }, { name = "rich", specifier = ">=14.0.0" }, { name = "sounddevice", specifier = ">=0.5.1" }, - { name = "textual", specifier = ">=8.1.1" }, + { name = "textual", specifier = ">=8.2.1" }, { name = "textual-speedups", specifier = ">=0.2.1" }, { name = "tomli-w", specifier = ">=1.2.0" }, { name = "tree-sitter", specifier = ">=0.25.2" }, @@ -1901,7 +1926,7 @@ wheels = [ [[package]] name = "textual" -version = "8.1.1" +version = "8.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, @@ -1911,9 +1936,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/23/8c709655c5f2208ee82ab81b8104802421865535c278a7649b842b129db1/textual-8.1.1.tar.gz", hash = "sha256:eef0256a6131f06a20ad7576412138c1f30f92ddeedd055953c08d97044bc317", size = 1843002, upload-time = "2026-03-10T10:01:38.493Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/07/766ad19cf2b15cae2d79e0db46a1b783b62316e9ff3e058e7424b2a4398b/textual-8.2.1.tar.gz", hash = "sha256:4176890e9cd5c95dcdd206541b2956b0808e74c8c36381c88db53dcb45237451", size = 1848386, upload-time = "2026-03-29T03:57:32.242Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/21/421b02bf5943172b7a9320712a5e0d74a02a8f7597284e3f8b5b06c70b8d/textual-8.1.1-py3-none-any.whl", hash = "sha256:6712f96e335cd782e76193dee16b9c8875fe0699d923bc8d3f1228fd23e773a6", size = 719598, upload-time = "2026-03-10T10:01:48.318Z" }, + { url = "https://files.pythonhosted.org/packages/25/09/c6f000c2e3702036e593803319af02feee58a662528d0d5728a37e1cf81b/textual-8.2.1-py3-none-any.whl", hash = "sha256:746cbf947a8ca875afc09779ef38cadbc7b9f15ac886a5090f7099fef5ade990", size = 723871, upload-time = "2026-03-29T03:57:34.334Z" }, ] [[package]] diff --git a/vibe/__init__.py b/vibe/__init__.py index 5ae11e7..6bb0182 100644 --- a/vibe/__init__.py +++ b/vibe/__init__.py @@ -3,4 +3,4 @@ from __future__ import annotations from pathlib import Path VIBE_ROOT = Path(__file__).parent -__version__ = "2.7.4" +__version__ = "2.7.5" diff --git a/vibe/acp/tools/builtins/search_replace.py b/vibe/acp/tools/builtins/search_replace.py index b0883a3..f8557be 100644 --- a/vibe/acp/tools/builtins/search_replace.py +++ b/vibe/acp/tools/builtins/search_replace.py @@ -19,10 +19,12 @@ from vibe.core.tools.builtins.search_replace import ( SearchReplaceResult, ) from vibe.core.types import ToolCallEvent, ToolResultEvent +from vibe.core.utils.io import ReadSafeResult class AcpSearchReplaceState(BaseToolState, AcpToolState): file_backup_content: str | None = None + file_backup_encoding: str = "utf-8" class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]): @@ -35,7 +37,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]): def _get_tool_state_class(cls) -> type[AcpSearchReplaceState]: return AcpSearchReplaceState - async def _read_file(self, file_path: Path) -> str: + async def _read_file(self, file_path: Path) -> ReadSafeResult: client, session_id, _ = self._load_state() await self._send_in_progress_session_update() @@ -48,7 +50,8 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]): raise ToolError(f"Unexpected error reading {file_path}: {e}") from e self.state.file_backup_content = response.content - return response.content + self.state.file_backup_encoding = "utf-8" + return ReadSafeResult(response.content, "utf-8") async def _backup_file(self, file_path: Path) -> None: if self.state.file_backup_content is None: @@ -57,9 +60,10 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]): await self._write_file( file_path.with_suffix(file_path.suffix + ".bak"), self.state.file_backup_content, + self.state.file_backup_encoding, ) - async def _write_file(self, file_path: Path, content: str) -> None: + async def _write_file(self, file_path: Path, content: str, encoding: str) -> None: client, session_id, _ = self._load_state() try: diff --git a/vibe/cli/cli.py b/vibe/cli/cli.py index 4f7c635..c436810 100644 --- a/vibe/cli/cli.py +++ b/vibe/cli/cli.py @@ -207,11 +207,14 @@ def run_cli(args: argparse.Namespace) -> None: client_name="vibe_cli", client_version=__version__, ), + defer_heavy_init=True, ) if loaded_session: _resume_previous_session(agent_loop, *loaded_session) + agent_loop.start_deferred_init() + run_textual_ui( agent_loop=agent_loop, startup=StartupOptions( diff --git a/vibe/cli/entrypoint.py b/vibe/cli/entrypoint.py index 1d04a32..8870329 100644 --- a/vibe/cli/entrypoint.py +++ b/vibe/cli/entrypoint.py @@ -10,7 +10,7 @@ from rich import print as rprint from vibe import __version__ from vibe.core.agents.models import BuiltinAgentName from vibe.core.config.harness_files import init_harness_files_manager -from vibe.core.trusted_folders import has_trustable_content, trusted_folders_manager +from vibe.core.trusted_folders import find_trustable_files, trusted_folders_manager from vibe.setup.trusted_folders.trust_folder_dialog import ( TrustDialogQuitException, ask_trust_folder, @@ -118,7 +118,12 @@ def check_and_resolve_trusted_folder() -> None: ) sys.exit(1) - if not has_trustable_content(cwd) or cwd.resolve() == Path.home().resolve(): + if cwd.resolve() == Path.home().resolve(): + return + + detected_files = find_trustable_files(cwd) + + if not detected_files: return is_folder_trusted = trusted_folders_manager.is_trusted(cwd) @@ -127,7 +132,7 @@ def check_and_resolve_trusted_folder() -> None: return try: - is_folder_trusted = ask_trust_folder(cwd) + is_folder_trusted = ask_trust_folder(cwd, detected_files) except (KeyboardInterrupt, EOFError, TrustDialogQuitException): sys.exit(0) except Exception as e: diff --git a/vibe/cli/history_manager.py b/vibe/cli/history_manager.py index b718213..b181b4c 100644 --- a/vibe/cli/history_manager.py +++ b/vibe/cli/history_manager.py @@ -3,6 +3,8 @@ from __future__ import annotations import json from pathlib import Path +from vibe.core.utils.io import read_safe + class HistoryManager: def __init__(self, history_file: Path, max_entries: int = 100) -> None: @@ -18,20 +20,22 @@ class HistoryManager: return try: - with self.history_file.open("r", encoding="utf-8") as f: - entries = [] - for raw_line in f: - raw_line = raw_line.rstrip("\n\r") - if not raw_line: - continue - try: - entry = json.loads(raw_line) - except json.JSONDecodeError: - entry = raw_line - entries.append(entry if isinstance(entry, str) else str(entry)) - self._entries = entries[-self.max_entries :] - except (OSError, UnicodeDecodeError): + text = read_safe(self.history_file).text + except OSError: self._entries = [] + return + + entries = [] + for raw_line in text.splitlines(): + raw_line = raw_line.rstrip("\n\r") + if not raw_line: + continue + try: + entry = json.loads(raw_line) + except json.JSONDecodeError: + entry = raw_line + entries.append(entry if isinstance(entry, str) else str(entry)) + self._entries = entries[-self.max_entries :] def _save_history(self) -> None: try: diff --git a/vibe/cli/narrator_manager/narrator_manager.py b/vibe/cli/narrator_manager/narrator_manager.py index 826fef2..0d1c263 100644 --- a/vibe/cli/narrator_manager/narrator_manager.py +++ b/vibe/cli/narrator_manager/narrator_manager.py @@ -7,6 +7,7 @@ from vibe.cli.narrator_manager.narrator_manager_port import ( NarratorManagerListener, NarratorState, ) +from vibe.cli.narrator_manager.telemetry import ReadAloudTrackingState from vibe.cli.turn_summary import ( NoopTurnSummary, TurnSummaryResult, @@ -24,16 +25,21 @@ if TYPE_CHECKING: from vibe.cli.turn_summary import TurnSummaryPort from vibe.core.audio_player.audio_player_port import AudioPlayerPort from vibe.core.config import VibeConfig + from vibe.core.telemetry.send import TelemetryClient from vibe.core.tts.tts_client_port import TTSClientPort from vibe.core.types import BaseEvent class NarratorManager: def __init__( - self, config_getter: Callable[[], VibeConfig], audio_player: AudioPlayerPort + self, + config_getter: Callable[[], VibeConfig], + audio_player: AudioPlayerPort, + telemetry_client: TelemetryClient | None = None, ) -> None: self._config_getter = config_getter self._audio_player = audio_player + self._telemetry_client = telemetry_client config = config_getter() self._turn_summary: TurnSummaryPort = self._make_turn_summary(config) self._turn_summary.on_summary = self._on_turn_summary @@ -43,6 +49,7 @@ class NarratorManager: self._cancel_summary: Callable[[], bool] | None = None self._close_tasks: set[asyncio.Task[Any]] = set() self._listeners: list[NarratorManagerListener] = [] + self._tracking = ReadAloudTrackingState() @property def state(self) -> NarratorState: @@ -99,8 +106,12 @@ class NarratorManager: ): self._cancel_summary = cancel_summary self._set_state(NarratorState.SUMMARIZING) + self._tracking.reset() + self._on_read_aloud_requested() def cancel(self) -> None: + if self._state != NarratorState.IDLE: + self._on_read_aloud_ended("canceled") if self._cancel_summary is not None: self._cancel_summary() self._cancel_summary = None @@ -178,17 +189,65 @@ class NarratorManager: loop = asyncio.get_running_loop() tts_result = await self._tts_client.speak(text) self._set_state(NarratorState.SPEAKING) + self._tracking.mark_play_started() + self._on_read_aloud_play_started() self._audio_player.play( tts_result.audio_data, AudioFormat.WAV, on_finished=lambda: loop.call_soon_threadsafe( - self._set_state, NarratorState.IDLE + self._on_playback_finished ), ) - except Exception: + except Exception as exc: logger.warning("TTS speak failed", exc_info=True) + self._on_read_aloud_ended("error", error_type=type(exc).__name__) self._set_state(NarratorState.IDLE) + def _on_playback_finished(self) -> None: + if self._state != NarratorState.SPEAKING: + return + self._on_read_aloud_ended("completed") + self._set_state(NarratorState.IDLE) + + def _on_read_aloud_requested(self) -> None: + if not self._telemetry_client: + return + self._telemetry_client.send_telemetry_event( + "vibe.read_aloud.requested", + { + "read_aloud_session_id": self._tracking.session_id, + "trigger": "autoplay_next_message", + }, + ) + + def _on_read_aloud_play_started(self) -> None: + if not self._telemetry_client: + return + self._telemetry_client.send_telemetry_event( + "vibe.read_aloud.play_started", + { + "read_aloud_session_id": self._tracking.session_id, + "time_to_first_read_s": self._tracking.time_to_first_read_s(), + "speed_selection": None, + }, + ) + + def _on_read_aloud_ended( + self, status: str, *, error_type: str | None = None + ) -> None: + if not self._telemetry_client: + return + self._telemetry_client.send_telemetry_event( + "vibe.read_aloud.ended", + { + "read_aloud_session_id": self._tracking.session_id, + "status": status, + "error_type": error_type, + "speed_selection": None, + "elapsed_seconds": self._tracking.elapsed_since_play_s(), + }, + ) + def _set_state(self, state: NarratorState) -> None: self._state = state for listener in list(self._listeners): diff --git a/vibe/cli/narrator_manager/telemetry.py b/vibe/cli/narrator_manager/telemetry.py new file mode 100644 index 0000000..b7ce496 --- /dev/null +++ b/vibe/cli/narrator_manager/telemetry.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import dataclass +import time +import uuid + + +@dataclass +class ReadAloudTrackingState: + session_id: str = "" + request_time: float = 0.0 + play_start_time: float = 0.0 + + def reset(self) -> None: + self.session_id = str(uuid.uuid4()) + self.request_time = time.monotonic() + self.play_start_time = 0.0 + + def mark_play_started(self) -> None: + self.play_start_time = time.monotonic() + + def time_to_first_read_s(self) -> float: + if self.play_start_time == 0.0 or self.request_time == 0.0: + return 0.0 + return self.play_start_time - self.request_time + + def elapsed_since_play_s(self) -> float: + if self.play_start_time == 0.0: + return 0.0 + return time.monotonic() - self.play_start_time diff --git a/vibe/cli/profiler.py b/vibe/cli/profiler.py index cc8c597..f472805 100644 --- a/vibe/cli/profiler.py +++ b/vibe/cli/profiler.py @@ -71,10 +71,18 @@ def stop_and_print() -> None: output_path = Path(f"{_state.label}-profile.html") output_path.write_text(_state.profiler.output_html(), encoding="utf-8") + + text_path = Path(f"{_state.label}-profile.txt") + text_path.write_text(_state.profiler.output_text(color=False), encoding="utf-8") + print( f"\n[profiler:{_state.label}] Saved HTML profile to {output_path.resolve()}", file=sys.stderr, ) + print( + f"[profiler:{_state.label}] Saved text profile to {text_path.resolve()}", + file=sys.stderr, + ) print(_state.profiler.output_text(color=True), file=sys.stderr) _state.profiler = None diff --git a/vibe/cli/terminal_setup.py b/vibe/cli/terminal_setup.py index 2dd2642..e132bcb 100644 --- a/vibe/cli/terminal_setup.py +++ b/vibe/cli/terminal_setup.py @@ -191,7 +191,7 @@ def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult: def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]: if keybindings_path.exists(): - content = read_safe(keybindings_path) + content = read_safe(keybindings_path).text return _parse_keybindings(content) keybindings_path.parent.mkdir(parents=True, exist_ok=True) return [] diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index 0a5942e..0176f0f 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -370,6 +370,7 @@ class VibeApp(App): # noqa: PLR0904 self._rewind_mode = False self._rewind_highlighted_widget: UserMessage | None = None + self._fatal_init_error = False @property def config(self) -> VibeConfig: @@ -449,6 +450,8 @@ class VibeApp(App): # noqa: PLR0904 self.call_after_refresh(self._refresh_banner) + self.run_worker(self._watch_init_completion(), exclusive=False) + if self._show_resume_picker: self.run_worker(self._show_session_picker(), exclusive=False) elif self._initial_prompt or self._teleport_on_start: @@ -457,6 +460,37 @@ class VibeApp(App): # noqa: PLR0904 gc.collect() gc.freeze() + async def _watch_init_completion(self) -> None: + """Show 'Initializing' loading indicator until background init finishes.""" + init_widget = None + try: + if not self.agent_loop.is_initialized: + await self._ensure_loading_widget("Initializing") + init_widget = self._loading_widget + await self.agent_loop.wait_for_init() + except Exception as e: + await self._mount_and_scroll( + ErrorMessage( + f"Background initialization failed: {e}", + collapsed=self._tools_collapsed, + ) + ) + await self._mount_and_scroll( + Static("Press any key to exit...", classes="error-hint") + ) + if self._chat_input_container: + self._chat_input_container.disabled = True + self._chat_input_container.display = False + self._fatal_init_error = True + finally: + if self._loading_widget is init_widget: + await self._remove_loading_widget() + self._refresh_banner() + try: + self.query_one(MCPApp).refresh_index() + except Exception: + pass + def _process_initial_prompt(self) -> None: if self._teleport_on_start: self.run_worker( @@ -470,6 +504,10 @@ class VibeApp(App): # noqa: PLR0904 def _is_file_watcher_enabled(self) -> bool: return self.config.file_watcher_for_autocomplete + def on_key(self) -> None: + if self._fatal_init_error: + self.exit() + async def on_chat_input_container_submitted( self, event: ChatInputContainer.Submitted ) -> None: @@ -754,7 +792,7 @@ class VibeApp(App): # noqa: PLR0904 self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill") try: - skill_content = read_safe(skill_info.skill_path) + skill_content = read_safe(skill_info.skill_path).text except OSError as e: await self._mount_and_scroll( ErrorMessage( @@ -1059,9 +1097,17 @@ class VibeApp(App): # noqa: PLR0904 self._agent_running = True await self._remove_loading_widget() - await self._ensure_loading_widget() try: + show_init_spinner = not self.agent_loop.is_initialized + if show_init_spinner: + await self._ensure_loading_widget("Initializing") + await self.agent_loop.wait_for_init() + if show_init_spinner: + await self._remove_loading_widget() + self._refresh_banner() + + await self._ensure_loading_widget() rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd()) self._narrator_manager.cancel() self._narrator_manager.on_turn_start(rendered_prompt) @@ -1088,6 +1134,11 @@ class VibeApp(App): # noqa: PLR0904 except Exception as e: await self._handle_turn_error() + # _watch_init_completion already rendered the fatal startup error + # and told the user to exit -- don't duplicate the message. + if self._fatal_init_error: + return + message = str(e) if isinstance(e, RateLimitError): message = self._rate_limit_message() @@ -2542,7 +2593,9 @@ class VibeApp(App): # noqa: PLR0904 def _make_default_narrator_manager(self) -> NarratorManager: return NarratorManager( - config_getter=lambda: self.config, audio_player=AudioPlayer() + config_getter=lambda: self.config, + audio_player=AudioPlayer(), + telemetry_client=self.agent_loop.telemetry_client, ) diff --git a/vibe/cli/textual_ui/external_editor.py b/vibe/cli/textual_ui/external_editor.py index da1f297..291929c 100644 --- a/vibe/cli/textual_ui/external_editor.py +++ b/vibe/cli/textual_ui/external_editor.py @@ -26,7 +26,7 @@ class ExternalEditor: parts = shlex.split(editor) subprocess.run([*parts, filepath], check=True) - content = read_safe(Path(filepath)).rstrip() + content = read_safe(Path(filepath)).text.rstrip() return content if content != initial_content else None except (OSError, subprocess.CalledProcessError): return diff --git a/vibe/cli/textual_ui/widgets/banner/banner.py b/vibe/cli/textual_ui/widgets/banner/banner.py index 9477066..6210f4d 100644 --- a/vibe/cli/textual_ui/widgets/banner/banner.py +++ b/vibe/cli/textual_ui/widgets/banner/banner.py @@ -68,6 +68,8 @@ class Banner(Static): self.state = self._initial_state def watch_state(self) -> None: + if not self.is_mounted: + return self.query_one("#banner-model", NoMarkupStatic).update(self.state.active_model) self.query_one("#banner-meta-counts", NoMarkupStatic).update( self._format_meta_counts() diff --git a/vibe/cli/textual_ui/widgets/mcp_app.py b/vibe/cli/textual_ui/widgets/mcp_app.py index a228119..e08aef7 100644 --- a/vibe/cli/textual_ui/widgets/mcp_app.py +++ b/vibe/cli/textual_ui/widgets/mcp_app.py @@ -78,6 +78,11 @@ class MCPApp(Container): self._refresh_view(self._viewing_server) self.query_one(OptionList).focus() + def refresh_index(self) -> None: + """Re-snapshot the tool index (e.g. after deferred MCP discovery).""" + self._index = collect_mcp_tool_index(self._mcp_servers, self._tool_manager) + self._refresh_view(self._viewing_server) + def on_descendant_blur(self, _event: DescendantBlur) -> None: self.query_one(OptionList).focus() diff --git a/vibe/cli/textual_ui/widgets/teleport_message.py b/vibe/cli/textual_ui/widgets/teleport_message.py index 1631901..f6d1d4e 100644 --- a/vibe/cli/textual_ui/widgets/teleport_message.py +++ b/vibe/cli/textual_ui/widgets/teleport_message.py @@ -15,7 +15,7 @@ class TeleportMessage(StatusMessage): if self._error: return f"Teleport failed: {self._error}" if self._final_url: - return f"Teleported to Nuage: {self._final_url}" + return f"Teleported to a new async coding session: {self._final_url}" return self._status def set_status(self, status: str) -> None: diff --git a/vibe/cli/update_notifier/whats_new.py b/vibe/cli/update_notifier/whats_new.py index d1ece83..f44490b 100644 --- a/vibe/cli/update_notifier/whats_new.py +++ b/vibe/cli/update_notifier/whats_new.py @@ -24,7 +24,7 @@ def load_whats_new_content() -> str | None: if not whats_new_file.exists(): return None try: - content = read_safe(whats_new_file).strip() + content = read_safe(whats_new_file).text.strip() return content if content else None except OSError: return None diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index 4f4eee3..443dc00 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -3,9 +3,11 @@ from __future__ import annotations import asyncio from collections.abc import AsyncGenerator, Callable, Generator import contextlib +import copy from enum import StrEnum, auto from http import HTTPStatus from pathlib import Path +import threading from threading import Thread import time from typing import TYPE_CHECKING, Any, Literal @@ -152,6 +154,7 @@ class AgentLoop: def __init__( self, config: VibeConfig, + *, agent_name: str = BuiltinAgentName.DEFAULT, message_observer: Callable[[LLMMessage], None] | None = None, max_turns: int | None = None, @@ -160,8 +163,12 @@ class AgentLoop: enable_streaming: bool = False, entrypoint_metadata: EntrypointMetadata | None = None, is_subagent: bool = False, + defer_heavy_init: bool = False, ) -> None: self._base_config = config + self._init_complete = threading.Event() + self._init_error: Exception | None = None + self.mcp_registry = MCPRegistry() self.agent_manager = AgentManager( lambda: self._base_config, @@ -169,7 +176,9 @@ class AgentLoop: allow_subagent=is_subagent, ) self.tool_manager = ToolManager( - lambda: self.config, mcp_registry=self.mcp_registry + lambda: self.config, + mcp_registry=self.mcp_registry, + defer_mcp=defer_heavy_init, ) self.skill_manager = SkillManager(lambda: self.config) self.message_observer = message_observer @@ -190,11 +199,18 @@ class AgentLoop: self._setup_middleware() system_prompt = get_universal_system_prompt( - self.tool_manager, self.config, self.skill_manager, self.agent_manager + self.tool_manager, + self.config, + self.skill_manager, + self.agent_manager, + include_git_status=not defer_heavy_init, ) system_message = LLMMessage(role=Role.system, content=system_prompt) self.messages = MessageList(initial=[system_message], observer=message_observer) + if not defer_heavy_init: + self._init_complete.set() + self.stats = AgentStats() self.approval_callback: ApprovalCallback | None = None self.user_input_callback: UserInputCallback | None = None @@ -233,6 +249,55 @@ class AgentLoop: ) thread.start() + def start_deferred_init(self) -> threading.Thread: + """Spawn a daemon thread that finishes deferred heavy I/O. + + Returns the started thread so callers can join if needed. + """ + thread = threading.Thread( + target=self._complete_init, daemon=True, name="agent_loop_init" + ) + thread.start() + return thread + + @property + def is_initialized(self) -> bool: + """Whether deferred initialization has completed (successfully or not).""" + return self._init_complete.is_set() + + def _complete_init(self) -> None: + """Run deferred heavy I/O: MCP discovery and git status. + + Intended to be called from a background thread when + ``defer_heavy_init=True`` was passed to ``__init__``. + """ + try: + self.tool_manager.integrate_mcp(raise_on_failure=True) + system_prompt = get_universal_system_prompt( + self.tool_manager, self.config, self.skill_manager, self.agent_manager + ) + self.messages.update_system_prompt(system_prompt) + except Exception as exc: + self._init_error = exc + finally: + self._init_complete.set() + + async def wait_for_init(self) -> None: + """Await deferred initialization from an async context. + + If deferred init failed, all callers raise the stored error. + + A copy of the stored exception is raised each time so that concurrent + callers do not share (and mutate) the same ``__traceback__`` object. + """ + if self.is_initialized: + if err := self._init_error: + raise copy.copy(err).with_traceback(err.__traceback__) + return + await asyncio.to_thread(self._init_complete.wait) + if err := self._init_error: + raise copy.copy(err).with_traceback(err.__traceback__) + @property def agent_profile(self) -> AgentProfile: return self.agent_manager.active_profile @@ -272,7 +337,7 @@ class AgentLoop: self.config.tools[tool_name]["permission"] = permission.value - def add_session_rule(self, rule: ApprovedRule) -> None: + def _add_session_rule(self, rule: ApprovedRule) -> None: self._session_rules.append(rule) def _is_permission_covered(self, tool_name: str, rp: RequiredPermission) -> bool: @@ -292,7 +357,7 @@ class AgentLoop: """Handle 'Allow Always' approval: add session rules or set tool-level permission.""" if required_permissions: for rp in required_permissions: - self.add_session_rule( + self._add_session_rule( ApprovedRule( tool_name=tool_name, scope=rp.scope, diff --git a/vibe/core/autocompletion/file_indexer/ignore_rules.py b/vibe/core/autocompletion/file_indexer/ignore_rules.py index b20cc45..e53154d 100644 --- a/vibe/core/autocompletion/file_indexer/ignore_rules.py +++ b/vibe/core/autocompletion/file_indexer/ignore_rules.py @@ -113,7 +113,7 @@ class IgnoreRules: gitignore_path = root / ".gitignore" if gitignore_path.exists(): try: - text = read_safe(gitignore_path) + text = read_safe(gitignore_path).text except Exception: return patterns diff --git a/vibe/core/autocompletion/path_prompt_adapter.py b/vibe/core/autocompletion/path_prompt_adapter.py index 826f61c..856b8d3 100644 --- a/vibe/core/autocompletion/path_prompt_adapter.py +++ b/vibe/core/autocompletion/path_prompt_adapter.py @@ -9,6 +9,7 @@ from vibe.core.autocompletion.path_prompt import ( PathResource, build_path_prompt_payload, ) +from vibe.core.utils.io import decode_safe DEFAULT_MAX_EMBED_BYTES = 256 * 1024 @@ -67,11 +68,7 @@ def _try_embed_text_resource( if not _is_probably_text(resource, data): return None - try: - text = data.decode("utf-8") - except UnicodeDecodeError: - return None - + text = decode_safe(data).text return {"type": "resource", "uri": resource.path.as_uri(), "text": text} diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py index 42b7bb0..d95fc77 100644 --- a/vibe/core/config/_settings.py +++ b/vibe/core/config/_settings.py @@ -11,6 +11,7 @@ from typing import Annotated, Any, Literal from urllib.parse import urljoin from dotenv import dotenv_values +from mistralai.client.models import SpeechOutputFormat from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( DEFAULT_TRACES_EXPORT_PATH, ) @@ -22,6 +23,7 @@ from pydantic_settings import ( PydanticBaseSettingsSource, SettingsConfigDict, ) +from pydantic_settings.sources.base import deep_update import tomli_w from vibe.core.config.harness_files import get_harness_files_manager @@ -96,6 +98,29 @@ class TomlFileSettingsSource(PydanticBaseSettingsSource): return self.toml_data +def _remove_none_values(value: Any) -> Any: + if isinstance(value, dict): + return { + key: cleaned_value + for key, item in value.items() + if (cleaned_value := _remove_none_values(item)) is not None + } + if isinstance(value, list): + return [ + cleaned_item + for item in value + if (cleaned_item := _remove_none_values(item)) is not None + ] + return value + + +def _to_toml_document(value: Any) -> dict[str, Any]: + jsonable = to_jsonable_python(value, fallback=str) + if not isinstance(jsonable, dict): + return {} + return _remove_none_values(jsonable) + + class ProjectContextConfig(BaseSettings): model_config = SettingsConfigDict(extra="ignore") @@ -121,16 +146,55 @@ class SessionLoggingConfig(BaseSettings): return str(Path(v).expanduser().resolve()) +DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY" +DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL = "https://console.mistral.ai" +DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL = "https://console.mistral.ai/api" + + class ProviderConfig(BaseModel): name: str api_base: str api_key_env_var: str = "" + browser_auth_base_url: str | None = None + browser_auth_api_base_url: str | None = None api_style: str = "openai" backend: Backend = Backend.GENERIC reasoning_field_name: str = "reasoning_content" project_id: str = "" region: str = "" + def _is_legacy_mistral_provider_without_backend(self) -> bool: + return ( + self.name == "mistral" + and self.backend == Backend.GENERIC + and "backend" not in self.model_fields_set + ) + + def _uses_mistral_browser_sign_in_defaults(self) -> bool: + return self.name == "mistral" and ( + self.backend == Backend.MISTRAL + or self._is_legacy_mistral_provider_without_backend() + ) + + @model_validator(mode="after") + def _apply_legacy_mistral_browser_auth_defaults(self) -> ProviderConfig: + if not self._uses_mistral_browser_sign_in_defaults(): + return self + + if self.browser_auth_base_url is None: + self.browser_auth_base_url = DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL + if self.browser_auth_api_base_url is None: + self.browser_auth_api_base_url = DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL + return self + + @property + def supports_browser_sign_in(self) -> bool: + return ( + (self.backend == Backend.MISTRAL or self.name == "mistral") + and bool(self.browser_auth_base_url) + and bool(self.browser_auth_api_base_url) + ) + class TranscribeClient(StrEnum): MISTRAL = auto() @@ -289,7 +353,7 @@ class TTSModelConfig(BaseModel): provider: str alias: str voice: str = "gb_jane_neutral" - response_format: str = "wav" + response_format: SpeechOutputFormat = "wav" _default_alias_to_name = model_validator(mode="before")(_default_alias_to_name) @@ -301,7 +365,6 @@ class OtelSpanExporterConfig(BaseModel): headers: dict[str, str] | None = None -DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY" MISTRAL_OTEL_PATH = "/telemetry" _DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai" @@ -310,6 +373,8 @@ DEFAULT_PROVIDERS = [ name="mistral", api_base=f"{_DEFAULT_MISTRAL_SERVER_URL}/v1", api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY, + browser_auth_base_url=DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL, + browser_auth_api_base_url=DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL, backend=Backend.MISTRAL, ), ProviderConfig( @@ -343,6 +408,8 @@ DEFAULT_MODELS = [ ), ] +DEFAULT_ACTIVE_MODEL = DEFAULT_MODELS[0].alias + DEFAULT_TRANSCRIBE_PROVIDERS = [ TranscribeProviderConfig( name="mistral", @@ -375,7 +442,7 @@ DEFAULT_TTS_MODELS = [ class VibeConfig(BaseSettings): - active_model: str = "devstral-2" + active_model: str = DEFAULT_ACTIVE_MODEL vim_keybindings: bool = False disable_welcome_banner_animation: bool = False autocopy_to_clipboard: bool = True @@ -529,7 +596,8 @@ class VibeConfig(BaseSettings): def otel_span_exporter_config(self) -> OtelSpanExporterConfig | None: # When otel_endpoint is set explicitly, authentication is the user's responsibility # (via OTEL_EXPORTER_OTLP_* env vars), so headers are left empty. - # Otherwise endpoint and API key are derived from the first MISTRAL provider. + # Otherwise endpoint and API key are derived from the active provider if it's Mistral, + # or the first Mistral provider. traces_export_path = DEFAULT_TRACES_EXPORT_PATH.lstrip("/") if self.otel_endpoint: return OtelSpanExporterConfig( @@ -538,9 +606,7 @@ class VibeConfig(BaseSettings): ) ) - provider = next( - (p for p in self.providers if p.backend == Backend.MISTRAL), None - ) + provider = self.get_mistral_provider() if provider is not None: server_url = get_server_url_from_api_base(provider.api_base) @@ -578,7 +644,7 @@ class VibeConfig(BaseSettings): ".md" ) if custom_sp_path.is_file(): - return read_safe(custom_sp_path) + return read_safe(custom_sp_path).text raise MissingPromptFileError( self.system_prompt_id, *(str(d) for d in prompt_dirs) @@ -597,6 +663,15 @@ class VibeConfig(BaseSettings): return self.compaction_model return self.get_active_model() + def get_mistral_provider(self) -> ProviderConfig | None: + try: + active_provider = self.get_provider_for_model(self.get_active_model()) + if active_provider.backend == Backend.MISTRAL: + return active_provider + except ValueError: + pass + return next((p for p in self.providers if p.backend == Backend.MISTRAL), None) + def get_provider_for_model(self, model: ModelConfig) -> ProviderConfig: for provider in self.providers: if provider.name == model.provider: @@ -776,37 +851,8 @@ class VibeConfig(BaseSettings): if not get_harness_files_manager().persist_allowed: return current_config = TomlFileSettingsSource(cls).toml_data - - def deep_merge(target: dict, source: dict) -> None: - for key, value in source.items(): - if ( - key in target - and isinstance(target.get(key), dict) - and isinstance(value, dict) - ): - deep_merge(target[key], value) - elif ( - key in target - and isinstance(target.get(key), list) - and isinstance(value, list) - ): - if key in { - "providers", - "models", - "transcribe_providers", - "transcribe_models", - "tts_providers", - "tts_models", - "installed_agents", - }: - target[key] = value - else: - target[key] = list(set(value + target[key])) - else: - target[key] = value - - deep_merge(current_config, updates) - cls.dump_config(current_config) + merged_config = deep_update(current_config, updates) + cls.dump_config(merged_config) @classmethod def dump_config(cls, config: dict[str, Any]) -> None: @@ -815,8 +861,10 @@ class VibeConfig(BaseSettings): return target = mgr.config_file or mgr.user_config_file target.parent.mkdir(parents=True, exist_ok=True) + toml_document = _to_toml_document(config) + cls.model_validate(toml_document) with target.open("wb") as f: - tomli_w.dump(to_jsonable_python(config, exclude_none=True, fallback=str), f) + tomli_w.dump(toml_document, f) @classmethod def _migrate(cls) -> None: diff --git a/vibe/core/config/harness_files/_harness_manager.py b/vibe/core/config/harness_files/_harness_manager.py index 7bf9ac1..a0f9d42 100644 --- a/vibe/core/config/harness_files/_harness_manager.py +++ b/vibe/core/config/harness_files/_harness_manager.py @@ -10,7 +10,12 @@ from vibe.core.config.harness_files._paths import ( GLOBAL_SKILLS_DIR, GLOBAL_TOOLS_DIR, ) -from vibe.core.paths import AGENTS_MD_FILENAME, VIBE_HOME, walk_local_config_dirs_all +from vibe.core.paths import ( + AGENTS_MD_FILENAME, + VIBE_HOME, + ConfigWalkResult, + walk_local_config_dirs, +) from vibe.core.trusted_folders import trusted_folders_manager from vibe.core.utils.io import read_safe @@ -72,25 +77,23 @@ class HarnessFilesManager: d = GLOBAL_AGENTS_DIR.path return [d] if d.is_dir() else [] - def _walk_project_dirs( - self, - ) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]: + def _walk_project_dirs(self) -> ConfigWalkResult: workdir = self.trusted_workdir if workdir is None: - return ((), (), ()) - return walk_local_config_dirs_all(workdir) + return ConfigWalkResult() + return walk_local_config_dirs(workdir) @property def project_tools_dirs(self) -> list[Path]: - return list(self._walk_project_dirs()[0]) + return list(self._walk_project_dirs().tools) @property def project_skills_dirs(self) -> list[Path]: - return list(self._walk_project_dirs()[1]) + return list(self._walk_project_dirs().skills) @property def project_agents_dirs(self) -> list[Path]: - return list(self._walk_project_dirs()[2]) + return list(self._walk_project_dirs().agents) @property def user_config_file(self) -> Path: @@ -116,7 +119,7 @@ class HarnessFilesManager: return "" path = VIBE_HOME.path / AGENTS_MD_FILENAME try: - stripped = read_safe(path).strip() + stripped = read_safe(path).text.strip() return stripped if stripped else "" except (FileNotFoundError, OSError): return "" @@ -140,7 +143,7 @@ class HarnessFilesManager: break path = current / AGENTS_MD_FILENAME try: - stripped = read_safe(path).strip() + stripped = read_safe(path).text.strip() if stripped: docs.append((current, stripped)) except (FileNotFoundError, OSError): diff --git a/vibe/core/llm/backend/anthropic.py b/vibe/core/llm/backend/anthropic.py index c88a2c7..1bba3a3 100644 --- a/vibe/core/llm/backend/anthropic.py +++ b/vibe/core/llm/backend/anthropic.py @@ -454,7 +454,7 @@ class AnthropicAdapter(APIAdapter): return payload - def prepare_request( # noqa: PLR0913 + def prepare_request( self, *, model_name: str, diff --git a/vibe/core/llm/backend/base.py b/vibe/core/llm/backend/base.py index 741eb34..7a498d6 100644 --- a/vibe/core/llm/backend/base.py +++ b/vibe/core/llm/backend/base.py @@ -19,7 +19,7 @@ class PreparedRequest(NamedTuple): class APIAdapter(Protocol): endpoint: ClassVar[str] - def prepare_request( # noqa: PLR0913 + def prepare_request( self, *, model_name: str, diff --git a/vibe/core/llm/backend/generic.py b/vibe/core/llm/backend/generic.py index ba7399e..162b0c4 100644 --- a/vibe/core/llm/backend/generic.py +++ b/vibe/core/llm/backend/generic.py @@ -78,7 +78,7 @@ class OpenAIAdapter(APIAdapter): msg_dict["reasoning_content"] = msg_dict.pop(field_name) return msg_dict - def prepare_request( # noqa: PLR0913 + def prepare_request( self, *, model_name: str, diff --git a/vibe/core/llm/backend/reasoning_adapter.py b/vibe/core/llm/backend/reasoning_adapter.py index 243ce08..2b8d3fa 100644 --- a/vibe/core/llm/backend/reasoning_adapter.py +++ b/vibe/core/llm/backend/reasoning_adapter.py @@ -107,7 +107,7 @@ class ReasoningAdapter(APIAdapter): return payload - def prepare_request( # noqa: PLR0913 + def prepare_request( self, *, model_name: str, diff --git a/vibe/core/llm/backend/vertex.py b/vibe/core/llm/backend/vertex.py index a49ba04..2b3657f 100644 --- a/vibe/core/llm/backend/vertex.py +++ b/vibe/core/llm/backend/vertex.py @@ -64,7 +64,7 @@ class VertexAnthropicAdapter(AnthropicAdapter): super().__init__() self.credentials = VertexCredentials() - def prepare_request( # noqa: PLR0913 + def prepare_request( self, *, model_name: str, diff --git a/vibe/core/nuage/remote_workflow_event_translator.py b/vibe/core/nuage/remote_workflow_event_translator.py index 526377e..4f49f23 100644 --- a/vibe/core/nuage/remote_workflow_event_translator.py +++ b/vibe/core/nuage/remote_workflow_event_translator.py @@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator, Callable import json from typing import Any, cast +from jsonpatch import JsonPatch, JsonPatchException # type: ignore[import-untyped] from pydantic import BaseModel, ValidationError from vibe.core.nuage.agent_models import AgentCompletionState @@ -16,7 +17,6 @@ from vibe.core.nuage.events import ( CustomTaskTimedOut, JSONPatchAppend, JSONPatchPayload, - JSONPatchRemove, JSONPatchReplace, JSONPayload, WorkflowEvent, @@ -61,11 +61,57 @@ from vibe.core.types import ( ) _WAIT_FOR_INPUT_TASK_TYPE = "wait_for_input" +_STEER_INPUT_LABEL = "Send a message to steer..." # These names must match the remote workflow's tool naming convention _ASK_USER_QUESTION_TOOL = "ask_user_question" _SEND_USER_MESSAGE_TOOL = "send_user_message" +def _get_value_at_path(path: str, obj: Any) -> Any: + if not path or path == "/": + return obj + parts = path.split("/")[1:] + current = obj + for part in parts: + if current is None: + return None + if isinstance(current, dict) and part in current: + current = current[part] + elif isinstance(current, list): + try: + current = current[int(part)] + except (ValueError, IndexError): + return None + else: + return None + return current + + +def _set_value_at_path(path: str, obj: Any, value: Any) -> None: + if not path or path == "/": + return + parts = path.split("/")[1:] + current = obj + for part in parts[:-1]: + if isinstance(current, dict) and part in current: + current = current[part] + elif isinstance(current, list): + try: + current = current[int(part)] + except (ValueError, IndexError): + return + else: + return + last = parts[-1] + if isinstance(current, dict): + current[last] = value + elif isinstance(current, list): + try: + current[int(last)] = value + except (ValueError, IndexError): + pass + + class _RemoteTool( BaseTool[RemoteToolArgs, RemoteToolResult, BaseToolConfig, BaseToolState], ToolUIData[RemoteToolArgs, RemoteToolResult], @@ -146,6 +192,7 @@ class RemoteWorkflowEventTranslator: self._pending_input_request: PendingInputRequest | None = None self._pending_question_prompt: str | None = None self._pending_ask_user_question_call_id: str | None = None + self._steer_task_ids: set[str] = set() self._last_status: WorkflowExecutionStatus | None = None @property @@ -315,12 +362,18 @@ class RemoteWorkflowEventTranslator: payload_value = event.attributes.payload.value label: str | None = None + if isinstance(payload_value, dict): + label = payload_value.get("label") + + if label == _STEER_INPUT_LABEL: + self._steer_task_ids.add(event.attributes.custom_task_id) + return [] + if isinstance(payload_value, dict): self._pending_input_request = PendingInputRequest.model_validate({ "task_id": event.attributes.custom_task_id, **payload_value, }) - label = self._pending_input_request.label events: list[BaseEvent] = [] if label: @@ -345,6 +398,10 @@ class RemoteWorkflowEventTranslator: if event.attributes.custom_task_type != _WAIT_FOR_INPUT_TASK_TYPE: return None + if event.attributes.custom_task_id in self._steer_task_ids: + self._steer_task_ids.discard(event.attributes.custom_task_id) + return [] + self._pending_input_request = None self._pending_question_prompt = None ask_user_question_call_id = self._pending_ask_user_question_call_id @@ -431,53 +488,25 @@ class RemoteWorkflowEventTranslator: self, previous_state: dict[str, Any], payload: JSONPatchPayload ) -> dict[str, Any]: new_state = cast(dict[str, Any], self._json_safe_value(previous_state)) + for patch in payload.value: - path = [part for part in patch.path.split("/") if part] - if not path: - if isinstance(patch, JSONPatchReplace): - new_state = self._normalize_state(patch.value) - continue - - if isinstance(patch, JSONPatchRemove): - self._remove_path(new_state, path) - continue - if isinstance(patch, JSONPatchAppend): - current = self._get_path(new_state, path) - self._set_path(new_state, path, f"{current or ''}{patch.value}") - continue - - self._set_path(new_state, path, patch.value) + current = _get_value_at_path(patch.path, new_state) + _set_value_at_path( + patch.path, new_state, f"{current or ''}{patch.value}" + ) + elif isinstance(patch, JSONPatchReplace) and not patch.path.strip("/"): + new_state = self._normalize_state(patch.value) + else: + try: + new_state = JsonPatch([ + {"op": patch.op, "path": patch.path, "value": patch.value} + ]).apply(new_state) + except JsonPatchException: + pass return new_state - def _get_path(self, state: dict[str, Any], path: list[str]) -> Any: - current: Any = state - for part in path: - if not isinstance(current, dict): - return None - current = current.get(part) - return current - - def _set_path(self, state: dict[str, Any], path: list[str], value: Any) -> None: - current: dict[str, Any] = state - for part in path[:-1]: - child = current.get(part) - if not isinstance(child, dict): - child = {} - current[part] = child - current = child - current[path[-1]] = value - - def _remove_path(self, state: dict[str, Any], path: list[str]) -> None: - current: dict[str, Any] = state - for part in path[:-1]: - child = current.get(part) - if not isinstance(child, dict): - return - current = child - current.pop(path[-1], None) - def _completion_events( self, task_id: str, previous_state: dict[str, Any], state: dict[str, Any] ) -> list[BaseEvent]: diff --git a/vibe/core/paths/__init__.py b/vibe/core/paths/__init__.py index dc5780a..5135921 100644 --- a/vibe/core/paths/__init__.py +++ b/vibe/core/paths/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations from vibe.core.paths._local_config_walk import ( WALK_MAX_DEPTH, - has_config_dirs_nearby, - walk_local_config_dirs_all, + ConfigWalkResult, + walk_local_config_dirs, ) from vibe.core.paths._vibe_home import ( DEFAULT_TOOL_DIR, @@ -31,7 +31,7 @@ __all__ = [ "TRUSTED_FOLDERS_FILE", "VIBE_HOME", "WALK_MAX_DEPTH", + "ConfigWalkResult", "GlobalPath", - "has_config_dirs_nearby", - "walk_local_config_dirs_all", + "walk_local_config_dirs", ] diff --git a/vibe/core/paths/_local_config_walk.py b/vibe/core/paths/_local_config_walk.py index e2937d5..380943c 100644 --- a/vibe/core/paths/_local_config_walk.py +++ b/vibe/core/paths/_local_config_walk.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections import deque +from dataclasses import dataclass, field from functools import cache import logging import os @@ -21,24 +22,59 @@ WALK_MAX_DEPTH = 4 _MAX_DIRS = 2000 -def _collect_config_dirs_at( - path: Path, - entry_names: set[str], - tools: list[Path], - skills: list[Path], - agents: list[Path], +@dataclass(frozen=True) +class ConfigWalkResult: + """Aggregated results of a config directory walk.""" + + config_dirs: tuple[Path, ...] = () + tools: tuple[Path, ...] = () + skills: tuple[Path, ...] = () + agents: tuple[Path, ...] = () + + +@dataclass +class _ConfigWalkCollector: + """Mutable accumulator used during BFS, frozen into ConfigWalkResult at the end.""" + + config_dirs: list[Path] = field(default_factory=list) + tools: list[Path] = field(default_factory=list) + skills: list[Path] = field(default_factory=list) + agents: list[Path] = field(default_factory=list) + + def freeze(self) -> ConfigWalkResult: + return ConfigWalkResult( + config_dirs=tuple(self.config_dirs), + tools=tuple(self.tools), + skills=tuple(self.skills), + agents=tuple(self.agents), + ) + + +def _collect_at( + path: Path, entry_names: set[str], collector: _ConfigWalkCollector ) -> None: """Check a single directory for .vibe/ and .agents/ config subdirs.""" - if _VIBE_DIR in entry_names: + if _VIBE_DIR in entry_names and (vibe_dir := path / _VIBE_DIR).is_dir(): + has_content = False if (candidate := path / _TOOLS_SUBDIR).is_dir(): - tools.append(candidate) + collector.tools.append(candidate) + has_content = True if (candidate := path / _VIBE_SKILLS_SUBDIR).is_dir(): - skills.append(candidate) + collector.skills.append(candidate) + has_content = True if (candidate := path / _AGENTS_SUBDIR).is_dir(): - agents.append(candidate) - if _AGENTS_DIR in entry_names: + collector.agents.append(candidate) + has_content = True + if ( + has_content + or (vibe_dir / "prompts").is_dir() + or (vibe_dir / "config.toml").is_file() + ): + collector.config_dirs.append(vibe_dir) + if _AGENTS_DIR in entry_names and (agents_dir := path / _AGENTS_DIR).is_dir(): if (candidate := path / _AGENTS_SKILLS_SUBDIR).is_dir(): - skills.append(candidate) + collector.skills.append(candidate) + collector.config_dirs.append(agents_dir) def _scandir_entries(path: Path) -> tuple[set[str], list[Path]]: @@ -68,60 +104,22 @@ def _scandir_entries(path: Path) -> tuple[set[str], list[Path]]: @cache -def walk_local_config_dirs_all( - root: Path, -) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]: +def walk_local_config_dirs( + root: Path, *, max_depth: int = WALK_MAX_DEPTH, max_dirs: int = _MAX_DIRS +) -> ConfigWalkResult: """Discover .vibe/ and .agents/ config directories under *root*. - Uses breadth-first search bounded by ``WALK_MAX_DEPTH`` and ``_MAX_DIRS`` + Uses breadth-first search bounded by *max_depth* and *max_dirs* to avoid unbounded traversal in large repositories. - """ - tools_dirs: list[Path] = [] - skills_dirs: list[Path] = [] - agents_dirs: list[Path] = [] + Returns a ``ConfigWalkResult`` containing both the parent config dirs + (for trust decisions) and the categorised subdirs (for loading). + """ + collector = _ConfigWalkCollector() resolved_root = root.resolve() queue: deque[tuple[Path, int]] = deque([(resolved_root, 0)]) visited = 0 - while queue and visited < _MAX_DIRS: - current, depth = queue.popleft() - visited += 1 - - entry_names, children = _scandir_entries(current) - if not entry_names: - continue - - _collect_config_dirs_at( - current, entry_names, tools_dirs, skills_dirs, agents_dirs - ) - - if depth < WALK_MAX_DEPTH: - queue.extend((child, depth + 1) for child in children) - - if visited >= _MAX_DIRS: - logger.warning( - "Config directory scan reached directory limit (%d dirs) at %s", - _MAX_DIRS, - resolved_root, - ) - - return (tuple(tools_dirs), tuple(skills_dirs), tuple(agents_dirs)) - - -def has_config_dirs_nearby( - root: Path, *, max_depth: int = WALK_MAX_DEPTH, max_dirs: int = 200 -) -> bool: - """Quick check for .vibe/ or .agents/ config dirs in the near subtree. - - Returns ``True`` as soon as any config directory is found, without - enumerating all of them. - """ - resolved = root.resolve() - queue: deque[tuple[Path, int]] = deque([(resolved, 0)]) - visited = 0 - found: list[Path] = [] - while queue and visited < max_dirs: current, depth = queue.popleft() visited += 1 @@ -130,11 +128,16 @@ def has_config_dirs_nearby( if not entry_names: continue - _collect_config_dirs_at(current, entry_names, found, found, found) - if found: - return True + _collect_at(current, entry_names, collector) if depth < max_depth: queue.extend((child, depth + 1) for child in children) - return False + if visited >= max_dirs: + logger.warning( + "Config directory scan reached directory limit (%d dirs) at %s", + max_dirs, + resolved_root, + ) + + return collector.freeze() diff --git a/vibe/core/prompts/__init__.py b/vibe/core/prompts/__init__.py index 97e8392..1991f8e 100644 --- a/vibe/core/prompts/__init__.py +++ b/vibe/core/prompts/__init__.py @@ -15,7 +15,7 @@ class Prompt(StrEnum): return (_PROMPTS_DIR / self.value).with_suffix(".md") def read(self) -> str: - return read_safe(self.path).strip() + return read_safe(self.path).text.strip() class SystemPrompt(Prompt): diff --git a/vibe/core/session/session_loader.py b/vibe/core/session/session_loader.py index c065396..b11a5c8 100644 --- a/vibe/core/session/session_loader.py +++ b/vibe/core/session/session_loader.py @@ -34,21 +34,19 @@ class SessionLoader: return False try: - with metadata_path.open("r", encoding="utf-8", errors="ignore") as f: - metadata = json.load(f) + metadata = json.loads(read_safe(metadata_path).text) if not isinstance(metadata, dict): return False - with messages_path.open("r", encoding="utf-8", errors="ignore") as f: - has_messages = False - for line in f: - has_messages = True - message = json.loads(line) - if not isinstance(message, dict): - return False + has_messages = False + for line in read_safe(messages_path).text.splitlines(): + has_messages = True + message = json.loads(line) + if not isinstance(message, dict): + return False if not has_messages: return False - except (OSError, UnicodeDecodeError, json.JSONDecodeError): + except (OSError, json.JSONDecodeError): return False return True @@ -144,8 +142,7 @@ class SessionLoader: metadata_path = session_dir / METADATA_FILENAME try: - with metadata_path.open("r", encoding="utf-8") as f: - metadata = json.load(f) + metadata = json.loads(read_safe(metadata_path).text) except (OSError, json.JSONDecodeError): continue @@ -182,7 +179,7 @@ class SessionLoader: raise ValueError(f"Session metadata not found at {session_dir}") try: - metadata_content = read_safe(metadata_path) + metadata_content = read_safe(metadata_path).text return SessionMetadata.model_validate_json(metadata_content) except ValueError: raise @@ -197,7 +194,7 @@ class SessionLoader: messages_filepath = filepath / MESSAGES_FILENAME try: - content = read_safe(messages_filepath).split("\n") + content = read_safe(messages_filepath).text.split("\n") if content and content[-1] == "": content.pop() except Exception as e: @@ -228,10 +225,7 @@ class SessionLoader: if metadata_filepath.exists(): try: - with metadata_filepath.open( - "r", encoding="utf-8", errors="ignore" - ) as f: - metadata = json.load(f) + metadata = json.loads(read_safe(metadata_filepath).text) except json.JSONDecodeError as e: raise ValueError( f"Session metadata contains invalid JSON (may have been corrupted): " diff --git a/vibe/core/session/session_logger.py b/vibe/core/session/session_logger.py index 362d9a7..88021d7 100644 --- a/vibe/core/session/session_logger.py +++ b/vibe/core/session/session_logger.py @@ -231,7 +231,7 @@ class SessionLogger: # Read old metadata and get total_messages try: if self.metadata_filepath.exists(): - raw = await read_safe_async(self.metadata_filepath) + raw = (await read_safe_async(self.metadata_filepath)).text old_metadata = json.loads(raw) old_total_messages = old_metadata["total_messages"] else: diff --git a/vibe/core/session/session_migration.py b/vibe/core/session/session_migration.py index cef852c..b9bd732 100644 --- a/vibe/core/session/session_migration.py +++ b/vibe/core/session/session_migration.py @@ -23,7 +23,7 @@ async def migrate_sessions(session_config: SessionLoggingConfig) -> int: session_files = list(Path(save_dir).glob(f"{session_config.session_prefix}_*.json")) for session_file in session_files: try: - session_data = read_safe(session_file) + session_data = read_safe(session_file).text session_json = json.loads(session_data) metadata = session_json["metadata"] messages = session_json["messages"] diff --git a/vibe/core/skills/manager.py b/vibe/core/skills/manager.py index 45a780f..3dd592e 100644 --- a/vibe/core/skills/manager.py +++ b/vibe/core/skills/manager.py @@ -107,7 +107,7 @@ class SkillManager: def _parse_skill_file(self, skill_path: Path) -> SkillInfo: try: - content = read_safe(skill_path) + content = read_safe(skill_path).text except OSError as e: raise SkillParseError(f"Cannot read file: {e}") from e diff --git a/vibe/core/system_prompt.py b/vibe/core/system_prompt.py index 7c9b998..ace8027 100644 --- a/vibe/core/system_prompt.py +++ b/vibe/core/system_prompt.py @@ -136,8 +136,8 @@ class ProjectContextProvider: except Exception as e: return f"Error getting git status: {e}" - def get_full_context(self) -> str: - git_status = self.get_git_status() + def get_full_context(self, *, include_git_status: bool = True) -> str: + git_status = self.get_git_status() if include_git_status else "" template = UtilityPrompt.PROJECT_CONTEXT.read() return Template(template).safe_substitute( @@ -249,6 +249,8 @@ def get_universal_system_prompt( config: VibeConfig, skill_manager: SkillManager, agent_manager: AgentManager, + *, + include_git_status: bool = True, ) -> str: sections = [config.system_prompt] @@ -285,7 +287,7 @@ def get_universal_system_prompt( else: context = ProjectContextProvider( config=config.project_context, root_path=Path.cwd() - ).get_full_context() + ).get_full_context(include_git_status=include_git_status) sections.append(context) diff --git a/vibe/core/telemetry/send.py b/vibe/core/telemetry/send.py index 4821edd..98d3064 100644 --- a/vibe/core/telemetry/send.py +++ b/vibe/core/telemetry/send.py @@ -4,19 +4,20 @@ import asyncio from collections.abc import Callable import os from typing import TYPE_CHECKING, Any, Literal +from urllib.parse import urljoin import httpx from vibe import __version__ -from vibe.core.config import VibeConfig +from vibe.core.config import ProviderConfig, VibeConfig from vibe.core.llm.format import ResolvedToolCall -from vibe.core.types import Backend -from vibe.core.utils import get_user_agent +from vibe.core.utils import get_server_url_from_api_base, get_user_agent if TYPE_CHECKING: from vibe.core.agent_loop import ToolDecision -DATALAKE_EVENTS_URL = "https://codestral.mistral.ai/v1/datalake/events" +_DEFAULT_TELEMETRY_BASE_URL = "https://codestral.mistral.ai" +_DATALAKE_EVENTS_PATH = "/v1/datalake/events" class TelemetryClient: @@ -31,31 +32,35 @@ class TelemetryClient: self._pending_tasks: set[asyncio.Task[Any]] = set() self.last_correlation_id: str | None = None - def _get_telemetry_user_agent(self) -> str: - try: - config = self._config_getter() - active_model = config.get_active_model() - provider = config.get_provider_for_model(active_model) - return get_user_agent(provider.backend) - except ValueError: - return get_user_agent(None) + def _get_telemetry_url(self, api_base: str) -> str: + base = get_server_url_from_api_base(api_base) or _DEFAULT_TELEMETRY_BASE_URL + return urljoin(base.rstrip("/"), _DATALAKE_EVENTS_PATH) def _get_mistral_api_key(self) -> str | None: - """Get the current API key from the active provider. + """Get the API key from the active provider if it's Mistral, + otherwise the first Mistral provider. Only returns an API key if the provider is a Mistral provider to avoid leaking third-party credentials to the telemetry endpoint. """ + provider_and_api_key = self._get_mistral_provider_and_api_key() + if provider_and_api_key is None: + return None + _, api_key = provider_and_api_key + return api_key + + def _get_mistral_provider_and_api_key(self) -> tuple[ProviderConfig, str] | None: try: - config = self._config_getter() - model = config.get_active_model() - provider = config.get_provider_for_model(model) - if provider.backend != Backend.MISTRAL: - return None - env_var = provider.api_key_env_var - return os.getenv(env_var) if env_var else None + provider = self._config_getter().get_mistral_provider() except ValueError: return None + if provider is None: + return None + env_var = provider.api_key_env_var + api_key = os.getenv(env_var) if env_var else None + if api_key is None: + return None + return provider, api_key def _is_enabled(self) -> bool: """Check if telemetry is enabled in the current config.""" @@ -83,10 +88,14 @@ class TelemetryClient: *, correlation_id: str | None = None, ) -> None: - mistral_api_key = self._get_mistral_api_key() - if mistral_api_key is None or not self._is_enabled(): + if not self._is_enabled(): return - user_agent = self._get_telemetry_user_agent() + provider_and_api_key = self._get_mistral_provider_and_api_key() + if provider_and_api_key is None: + return + provider, mistral_api_key = provider_and_api_key + telemetry_url = self._get_telemetry_url(provider.api_base) + user_agent = get_user_agent(provider.backend) if ( self._session_id_getter is not None and (session_id := self._session_id_getter()) is not None @@ -100,7 +109,7 @@ class TelemetryClient: async def _send() -> None: try: await self.client.post( - DATALAKE_EVENTS_URL, + telemetry_url, json=payload, headers={ "Content-Type": "application/json", diff --git a/vibe/core/teleport/git.py b/vibe/core/teleport/git.py index b236b6b..c1b2718 100644 --- a/vibe/core/teleport/git.py +++ b/vibe/core/teleport/git.py @@ -134,7 +134,9 @@ class GitRepository: try: self._repo = Repo(self._workdir, search_parent_directories=True) except InvalidGitRepositoryError as e: - raise ServiceTeleportNotSupportedError("Not a git repository") from e + raise ServiceTeleportNotSupportedError( + "Teleport requires a git repository. cd into a project with a .git directory and try again." + ) from e return self._repo def _find_github_remote(self, repo: Repo) -> tuple[str, str] | None: diff --git a/vibe/core/tools/base.py b/vibe/core/tools/base.py index d62fdc5..24fe0eb 100644 --- a/vibe/core/tools/base.py +++ b/vibe/core/tools/base.py @@ -167,7 +167,7 @@ class BaseTool[ prompt_dir = class_path.parent / "prompts" prompt_path = cls.prompt_path or prompt_dir / f"{class_path.stem}.md" - return read_safe(prompt_path) + return read_safe(prompt_path).text except (FileNotFoundError, TypeError, OSError): pass diff --git a/vibe/core/tools/builtins/exit_plan_mode.py b/vibe/core/tools/builtins/exit_plan_mode.py index 1e40e4c..b71a948 100644 --- a/vibe/core/tools/builtins/exit_plan_mode.py +++ b/vibe/core/tools/builtins/exit_plan_mode.py @@ -75,7 +75,7 @@ class ExitPlanMode( plan_content: str | None = None if ctx.plan_file_path and ctx.plan_file_path.is_file(): try: - plan_content = read_safe(ctx.plan_file_path) + plan_content = read_safe(ctx.plan_file_path).text except OSError as e: raise ToolError( f"Failed to read plan file at {ctx.plan_file_path}: {e}" diff --git a/vibe/core/tools/builtins/grep.py b/vibe/core/tools/builtins/grep.py index 2b515e4..d041451 100644 --- a/vibe/core/tools/builtins/grep.py +++ b/vibe/core/tools/builtins/grep.py @@ -167,7 +167,7 @@ class Grep( def _load_codeignore_patterns(self, codeignore_path: Path) -> list[str]: patterns = [] try: - content = read_safe(codeignore_path) + content = read_safe(codeignore_path).text for line in content.splitlines(): line = line.strip() if line and not line.startswith("#"): diff --git a/vibe/core/tools/builtins/read_file.py b/vibe/core/tools/builtins/read_file.py index 05af798..483515a 100644 --- a/vibe/core/tools/builtins/read_file.py +++ b/vibe/core/tools/builtins/read_file.py @@ -21,6 +21,7 @@ from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData from vibe.core.tools.utils import resolve_file_tool_permission from vibe.core.types import ToolStreamEvent from vibe.core.utils import VIBE_WARNING_TAG +from vibe.core.utils.io import decode_safe if TYPE_CHECKING: from vibe.core.types import ToolResultEvent @@ -73,8 +74,8 @@ class ReadFile( ToolUIData[ReadFileArgs, ReadFileResult], ): description: ClassVar[str] = ( - "Read a UTF-8 file, returning content from a specific line range. " - "Reading is capped by a byte limit for safety." + "Read a text file (encoding detected safely), returning content from a " + "specific line range. Reading is capped by a byte limit for safety." ) @final @@ -135,53 +136,38 @@ class ReadFile( async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult: try: - return await self._do_read_file(args, file_path, encoding="utf-8") - except (UnicodeDecodeError, ValueError): - return await self._do_read_file(args, file_path, errors="replace") - - async def _do_read_file( - self, - args: ReadFileArgs, - file_path: Path, - *, - encoding: str | None = None, - errors: str | None = None, - ) -> _ReadResult: - try: - lines_to_return: list[str] = [] + raw_lines: list[bytes] = [] bytes_read = 0 was_truncated = False - async with await anyio.Path(file_path).open( - encoding=encoding, errors=errors - ) as f: + async with await anyio.Path(file_path).open("rb") as f: line_index = 0 - async for line in f: + while raw_line := await f.readline(): if line_index < args.offset: line_index += 1 continue - if args.limit is not None and len(lines_to_return) >= args.limit: + if args.limit is not None and len(raw_lines) >= args.limit: break - line_bytes = len(line.encode("utf-8")) + line_bytes = len(raw_line) if bytes_read + line_bytes > self.config.max_read_bytes: was_truncated = True break - lines_to_return.append(line) + raw_lines.append(raw_line) bytes_read += line_bytes line_index += 1 - - return _ReadResult( - lines=lines_to_return, - bytes_read=bytes_read, - was_truncated=was_truncated, - ) - except OSError as exc: raise ToolError(f"Error reading {file_path}: {exc}") from exc + lines_to_return = decode_safe(b"".join(raw_lines)).text.splitlines( + keepends=True + ) + return _ReadResult( + lines=lines_to_return, bytes_read=bytes_read, was_truncated=was_truncated + ) + def _validate_inputs(self, args: ReadFileArgs) -> None: if not args.path.strip(): raise ToolError("Path cannot be empty") diff --git a/vibe/core/tools/builtins/search_replace.py b/vibe/core/tools/builtins/search_replace.py index cedad07..0032324 100644 --- a/vibe/core/tools/builtins/search_replace.py +++ b/vibe/core/tools/builtins/search_replace.py @@ -22,7 +22,7 @@ from vibe.core.tools.permissions import PermissionContext from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData from vibe.core.tools.utils import resolve_file_tool_permission from vibe.core.types import ToolResultEvent, ToolStreamEvent -from vibe.core.utils.io import read_safe_async +from vibe.core.utils.io import ReadSafeResult, read_safe_async SEARCH_REPLACE_BLOCK_RE = re.compile( r"<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE", flags=re.DOTALL @@ -131,7 +131,8 @@ class SearchReplace( ) -> AsyncGenerator[ToolStreamEvent | SearchReplaceResult, None]: file_path, search_replace_blocks = self._prepare_and_validate_args(args) - original_content = await self._read_file(file_path) + decoded = await self._read_file(file_path) + original_content = decoded.text block_result = self._apply_blocks( original_content, @@ -166,7 +167,7 @@ class SearchReplace( except Exception: pass - await self._write_file(file_path, modified_content) + await self._write_file(file_path, modified_content, decoded.encoding) yield SearchReplaceResult( file=str(file_path), @@ -221,7 +222,7 @@ class SearchReplace( return file_path, search_replace_blocks - async def _read_file(self, file_path: Path) -> str: + async def _read_file(self, file_path: Path) -> ReadSafeResult: try: return await read_safe_async(file_path, raise_on_error=True) except PermissionError: @@ -234,12 +235,16 @@ class SearchReplace( async def _backup_file(self, file_path: Path) -> None: shutil.copy2(file_path, file_path.with_suffix(file_path.suffix + ".bak")) - async def _write_file(self, file_path: Path, content: str) -> None: + async def _write_file(self, file_path: Path, content: str, encoding: str) -> None: try: async with await anyio.Path(file_path).open( - mode="w", encoding="utf-8" + mode="w", encoding=encoding ) as f: await f.write(content) + except UnicodeEncodeError as e: + raise ToolError( + f"Cannot encode patched content for {file_path} using {encoding!r}: {e}" + ) from e except PermissionError: raise ToolError(f"Permission denied writing to file: {file_path}") except OSError as e: diff --git a/vibe/core/tools/builtins/skill.py b/vibe/core/tools/builtins/skill.py index 3092104..33ef0d1 100644 --- a/vibe/core/tools/builtins/skill.py +++ b/vibe/core/tools/builtins/skill.py @@ -99,7 +99,7 @@ class Skill( ) try: - raw = read_safe(skill_info.skill_path) + raw = read_safe(skill_info.skill_path).text _, body = parse_frontmatter(raw) except (OSError, SkillParseError) as e: raise ToolError(f"Cannot load skill file: {e}") from e diff --git a/vibe/core/tools/manager.py b/vibe/core/tools/manager.py index f2c64d1..85ae852 100644 --- a/vibe/core/tools/manager.py +++ b/vibe/core/tools/manager.py @@ -7,6 +7,7 @@ import inspect from pathlib import Path import re import sys +import threading from typing import TYPE_CHECKING, Any from vibe.core.config.harness_files import get_harness_files_manager @@ -69,16 +70,21 @@ class ToolManager: self, config_getter: Callable[[], VibeConfig], mcp_registry: MCPRegistry | None = None, + *, + defer_mcp: bool = False, ) -> None: self._config_getter = config_getter self._mcp_registry = mcp_registry or MCPRegistry() self._instances: dict[str, BaseTool] = {} self._search_paths: list[Path] = self._compute_search_paths(self._config) + self._lock = threading.Lock() + self._mcp_integrated = False self._available: dict[str, type[BaseTool]] = { cls.get_name(): cls for cls in self._iter_tool_classes(self._search_paths) } - self._integrate_mcp() + if not defer_mcp: + self.integrate_mcp() @property def _config(self) -> VibeConfig: @@ -176,13 +182,15 @@ class ToolManager: @property def registered_tools(self) -> dict[str, type[BaseTool]]: - return self._available + with self._lock: + return dict(self._available) @property def available_tools(self) -> dict[str, type[BaseTool]]: - runtime_available = { - name: cls for name, cls in self._available.items() if cls.is_available() - } + with self._lock: + runtime_available = { + name: cls for name, cls in self._available.items() if cls.is_available() + } if self._config.enabled_tools: return { @@ -198,7 +206,14 @@ class ToolManager: } return runtime_available - def _integrate_mcp(self) -> None: + def integrate_mcp(self, *, raise_on_failure: bool = False) -> None: + """Discover and register MCP tools. + + Idempotent: subsequent calls after a successful integration are + no-ops to avoid redundant MCP discovery. + """ + if self._mcp_integrated: + return if not self._config.mcp_servers: return @@ -206,15 +221,20 @@ class ToolManager: mcp_tools = self._mcp_registry.get_tools(self._config.mcp_servers) except Exception as exc: logger.warning("MCP integration failed: %s", exc) + if raise_on_failure: + raise return - self._available.update(mcp_tools) + with self._lock: + self._available = {**self._available, **mcp_tools} + self._mcp_integrated = True logger.info( "MCP integration registered %d tools (via registry)", len(mcp_tools) ) def get_tool_config(self, tool_name: str) -> BaseToolConfig: - tool_class = self._available.get(tool_name) + with self._lock: + tool_class = self._available.get(tool_name) if tool_class: config_class = tool_class._get_tool_config_class() @@ -239,12 +259,12 @@ class ToolManager: if tool_name in self._instances: return self._instances[tool_name] - if tool_name not in self._available: - raise NoSuchToolError( - f"Unknown tool: {tool_name}. Available: {list(self._available.keys())}" - ) - - tool_class = self._available[tool_name] + with self._lock: + if tool_name not in self._available: + raise NoSuchToolError( + f"Unknown tool: {tool_name}. Available: {list(self._available.keys())}" + ) + tool_class = self._available[tool_name] self._instances[tool_name] = tool_class.from_config( lambda: self.get_tool_config(tool_name) ) diff --git a/vibe/core/trusted_folders.py b/vibe/core/trusted_folders.py index 5c4a21b..40180d6 100644 --- a/vibe/core/trusted_folders.py +++ b/vibe/core/trusted_folders.py @@ -8,7 +8,7 @@ import tomli_w from vibe.core.paths import ( AGENTS_MD_FILENAME, TRUSTED_FOLDERS_FILE, - has_config_dirs_nearby, + walk_local_config_dirs, ) @@ -16,12 +16,20 @@ def has_agents_md_file(path: Path) -> bool: return (path / AGENTS_MD_FILENAME).exists() -def has_trustable_content(path: Path) -> bool: - if (path / ".vibe").exists(): - return True +def find_trustable_files(path: Path) -> list[str]: + """Return relative paths of files/dirs that would modify the agent's behavior.""" + resolved = path.resolve() + found: list[str] = [] + if has_agents_md_file(path): - return True - return has_config_dirs_nearby(path) + found.append(AGENTS_MD_FILENAME) + + for config_dir in walk_local_config_dirs(path).config_dirs: + label = f"{config_dir.relative_to(resolved)}/" + if label not in found: + found.append(label) + + return found class TrustedFoldersManager: diff --git a/vibe/core/tts/mistral_tts_client.py b/vibe/core/tts/mistral_tts_client.py index ce79f55..401a59c 100644 --- a/vibe/core/tts/mistral_tts_client.py +++ b/vibe/core/tts/mistral_tts_client.py @@ -3,7 +3,8 @@ from __future__ import annotations import base64 import os -import httpx +from mistralai.client import Mistral +from mistralai.client.models import SpeechOutputFormat from vibe.core.config import TTSModelConfig, TTSProviderConfig from vibe.core.tts.tts_client_port import TTSResult @@ -11,34 +12,30 @@ from vibe.core.tts.tts_client_port import TTSResult class MistralTTSClient: def __init__(self, provider: TTSProviderConfig, model: TTSModelConfig) -> None: + self._api_key = os.getenv(provider.api_key_env_var, "") + self._server_url = provider.api_base self._model_name = model.name self._voice = model.voice - self._response_format = model.response_format - self._client = httpx.AsyncClient( - base_url=f"{provider.api_base}/v1", - headers={ - "Authorization": f"Bearer {os.getenv(provider.api_key_env_var, '')}", - "Content-Type": "application/json", - }, - timeout=60.0, - ) + self._response_format: SpeechOutputFormat = model.response_format + self._client: Mistral | None = None + + def _get_client(self) -> Mistral: + if self._client is None: + self._client = Mistral(api_key=self._api_key, server_url=self._server_url) + return self._client async def speak(self, text: str) -> TTSResult: - response = await self._client.post( - "/audio/speech", - json={ - "model": self._model_name, - "input": text, - "voice_id": self._voice, - "stream": False, - "response_format": self._response_format, - }, + client = self._get_client() + response = await client.audio.speech.complete_async( + model=self._model_name, + input=text, + voice_id=self._voice, + response_format=self._response_format, ) - response.raise_for_status() - - data = response.json() - audio_bytes = base64.b64decode(data["audio_data"]) + audio_bytes = base64.b64decode(response.audio_data) return TTSResult(audio_data=audio_bytes) async def close(self) -> None: - await self._client.aclose() + if self._client is not None: + await self._client.__aexit__(exc_type=None, exc_val=None, exc_tb=None) + self._client = None diff --git a/vibe/core/types.py b/vibe/core/types.py index 4e973f6..bd9438a 100644 --- a/vibe/core/types.py +++ b/vibe/core/types.py @@ -487,7 +487,13 @@ class MessageList(Sequence[LLMMessage]): hook() def update_system_prompt(self, new: str) -> None: - """Update the system prompt in place.""" + """Update the system prompt in place. + + Called from a background thread during deferred init. A single + list-item assignment is atomic under CPython's GIL, and the + ``_init_complete`` event ensures no ``act()`` call reads the + prompt concurrently, so no additional lock is needed here. + """ self._data[0] = LLMMessage(role=Role.system, content=new) @contextmanager diff --git a/vibe/core/utils/__init__.py b/vibe/core/utils/__init__.py index 16c78c6..bbab8ce 100644 --- a/vibe/core/utils/__init__.py +++ b/vibe/core/utils/__init__.py @@ -1,6 +1,6 @@ """Utilities package. Re-exports all public and test-used symbols from submodules. -Import read_safe/read_safe_async from vibe.core.utils.io and create_slug from +Import read_safe / read_safe_async / decode_safe (returns ReadSafeResult) from vibe.core.utils.io and create_slug from vibe.core.utils.slug when needed to avoid circular imports with config. """ diff --git a/vibe/core/utils/io.py b/vibe/core/utils/io.py index 2b59a18..49c7062 100644 --- a/vibe/core/utils/io.py +++ b/vibe/core/utils/io.py @@ -1,29 +1,79 @@ from __future__ import annotations +from collections.abc import Iterator +import locale from pathlib import Path +from typing import NamedTuple import anyio +from charset_normalizer import from_bytes -def read_safe(path: Path, *, raise_on_error: bool = False) -> str: - """Read a text file trying UTF-8 first, falling back to OS-default encoding. +class ReadSafeResult(NamedTuple): + """Text decoded from a file and the codec name that successfully decoded it.""" - On fallback, undecodable bytes are replaced with U+FFFD (REPLACEMENT CHARACTER). - When raise_on_error is True, decode errors propagate. + text: str + encoding: str + + +def _encodings_from_bom(raw: bytes) -> str | None: + if raw.startswith(b"\xef\xbb\xbf"): + return "utf-8-sig" + if raw.startswith(b"\xff\xfe\x00\x00"): + return "utf-32-le" + if raw.startswith(b"\x00\x00\xfe\xff"): + return "utf-32-be" + if raw.startswith(b"\xff\xfe"): + return "utf-16-le" + if raw.startswith(b"\xfe\xff"): + return "utf-16-be" + return None + + +def _encoding_from_best_match(raw: bytes) -> str | None: + if not (match := from_bytes(raw).best()): + return None + return match.encoding + + +def _get_candidate_encodings(raw: bytes) -> Iterator[str]: + """Yield candidate encodings lazily — expensive detection runs only if needed.""" + seen: set[str] = set() + yield "utf-8" + if (bom := _encodings_from_bom(raw)) and bom not in seen: + yield bom + if ( + locale_encoding := locale.getpreferredencoding(False) + ) and locale_encoding not in seen: + yield locale_encoding + if (best := _encoding_from_best_match(raw)) and best not in seen: + yield best + + +def decode_safe(raw: bytes, *, raise_on_error: bool = False) -> ReadSafeResult: + """Decode ``raw`` like :func:`read_safe` after ``read_bytes``. + + Tries UTF-8, locale, BOM, charset-normalizer, then UTF-8 (strict or replace). + ``UnicodeDecodeError`` can only occur in that last step when + ``raise_on_error`` is true. """ - try: - return path.read_text(encoding="utf-8") - except (UnicodeDecodeError, ValueError): - if raise_on_error: - return path.read_text() - return path.read_text(errors="replace") + for encoding in _get_candidate_encodings(raw): + try: + return ReadSafeResult(raw.decode(encoding), encoding) + except (LookupError, UnicodeDecodeError, ValueError): + pass + errors = "strict" if raise_on_error else "replace" + return ReadSafeResult(raw.decode("utf-8", errors=errors), "utf-8") -async def read_safe_async(path: Path, *, raise_on_error: bool = False) -> str: - apath = anyio.Path(path) - try: - return await apath.read_text(encoding="utf-8") - except (UnicodeDecodeError, ValueError): - if raise_on_error: - return await apath.read_text() - return await apath.read_text(errors="replace") +def read_safe(path: Path, *, raise_on_error: bool = False) -> ReadSafeResult: + """Read ``path`` and decode with :func:`decode_safe`.""" + return decode_safe(path.read_bytes(), raise_on_error=raise_on_error) + + +async def read_safe_async( + path: Path, *, raise_on_error: bool = False +) -> ReadSafeResult: + """Async :func:`read_safe` (``anyio``).""" + raw = await anyio.Path(path).read_bytes() + return decode_safe(raw, raise_on_error=raise_on_error) diff --git a/vibe/setup/onboarding/__init__.py b/vibe/setup/onboarding/__init__.py index bdf0314..d8b8795 100644 --- a/vibe/setup/onboarding/__init__.py +++ b/vibe/setup/onboarding/__init__.py @@ -26,6 +26,15 @@ def run_onboarding(app: App | None = None) -> None: case None: rprint("\n[yellow]Setup cancelled. See you next time![/]") sys.exit(0) + case str() as s if s.startswith("env_var_error:"): + env_key = s.removeprefix("env_var_error:") + rprint( + "\n[yellow]Could not save the API key because this provider is " + f"configured with an invalid environment variable name: {env_key}.[/]" + "\n[dim]The API key was not saved for this session. " + "Update the provider's `api_key_env_var` setting in your config and try again.[/]\n" + ) + sys.exit(1) case str() as s if s.startswith("save_error:"): err = s.removeprefix("save_error:") rprint( diff --git a/vibe/setup/onboarding/context.py b/vibe/setup/onboarding/context.py new file mode 100644 index 0000000..6828e51 --- /dev/null +++ b/vibe/setup/onboarding/context.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from dataclasses import dataclass +import os +import tomllib +from typing import Any + +from pydantic import BaseModel, Field, TypeAdapter, ValidationError + +from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig +from vibe.core.config._settings import ( + DEFAULT_ACTIVE_MODEL, + DEFAULT_MODELS, + DEFAULT_PROVIDERS, +) +from vibe.core.config.harness_files import get_harness_files_manager +from vibe.core.logger import logger + +_ONBOARDING_LIST_ADAPTER = TypeAdapter(list[Any]) + + +def _default_provider_payloads() -> list[dict[str, Any]]: + return [provider.model_dump(mode="json") for provider in DEFAULT_PROVIDERS] + + +def _default_model_payloads() -> list[dict[str, Any]]: + return [model.model_dump(mode="json") for model in DEFAULT_MODELS] + + +class _OnboardingSnapshot(BaseModel): + active_model: str = DEFAULT_ACTIVE_MODEL + providers: list[Any] = Field(default_factory=_default_provider_payloads) + models: list[Any] = Field(default_factory=_default_model_payloads) + + +_ONBOARDING_FIELDS = frozenset(_OnboardingSnapshot.model_fields) + + +def _can_resolve_provider_from_explicit_overrides( + explicit_overrides: dict[str, Any], +) -> bool: + return "providers" in explicit_overrides + + +def _find_env_value(name: str) -> str | None: + expected_name = name.upper() + for env_name, value in os.environ.items(): + if env_name.upper() == expected_name: + return value + return None + + +def _load_onboarding_toml_payload() -> dict[str, Any]: + try: + harness_files = get_harness_files_manager() + except RuntimeError: + return {} + + config_file = harness_files.config_file + if config_file is None: + return {} + + try: + with config_file.open("rb") as file: + toml_data = tomllib.load(file) + except FileNotFoundError: + return {} + except tomllib.TOMLDecodeError as err: + raise RuntimeError(f"Invalid TOML in {config_file}: {err}") from err + except OSError as err: + raise RuntimeError(f"Cannot read {config_file}: {err}") from err + + return { + field_name: toml_data[field_name] + for field_name in _ONBOARDING_FIELDS + if field_name in toml_data + } + + +def _load_onboarding_env_payload_for_fields( + field_names: frozenset[str], +) -> dict[str, Any]: + payload: dict[str, Any] = {} + + if ( + "active_model" in field_names + and (active_model := _find_env_value("VIBE_ACTIVE_MODEL")) is not None + ): + payload["active_model"] = active_model + if ( + "providers" in field_names + and (providers := _find_env_value("VIBE_PROVIDERS")) is not None + ): + payload["providers"] = _ONBOARDING_LIST_ADAPTER.validate_json(providers) + if ( + "models" in field_names + and (models := _find_env_value("VIBE_MODELS")) is not None + ): + payload["models"] = _ONBOARDING_LIST_ADAPTER.validate_json(models) + + return payload + + +def _explicit_onboarding_overrides(**overrides: Any) -> dict[str, Any]: + return { + field_name: value + for field_name, value in overrides.items() + if field_name in _ONBOARDING_FIELDS + } + + +def _build_onboarding_snapshot_payload(**overrides: Any) -> dict[str, Any]: + explicit_overrides = _explicit_onboarding_overrides(**overrides) + payload = _OnboardingSnapshot().model_dump() + + if explicit_overrides.keys() >= _ONBOARDING_FIELDS: + payload.update(explicit_overrides) + return payload + + try: + payload.update(_load_onboarding_toml_payload()) + except RuntimeError: + if not _can_resolve_provider_from_explicit_overrides(explicit_overrides): + raise + try: + payload.update( + _load_onboarding_env_payload_for_fields( + _ONBOARDING_FIELDS.difference(explicit_overrides) + ) + ) + except (ValidationError, ValueError): + if not _can_resolve_provider_from_explicit_overrides(explicit_overrides): + raise + payload.update(explicit_overrides) + return payload + + +def _validated_payloads[PayloadConfig: ModelConfig | ProviderConfig]( + payloads: list[Any], model_type: type[PayloadConfig] +) -> list[PayloadConfig]: + validated_payloads: list[PayloadConfig] = [] + for payload in payloads: + if isinstance(payload, model_type): + validated_payloads.append(payload) + continue + if not isinstance(payload, dict): + continue + try: + validated_payloads.append(model_type.model_validate(payload)) + except (ValidationError, ValueError): + continue + return validated_payloads + + +def _resolve_provider( + *, active_model: str, snapshot: _OnboardingSnapshot +) -> ProviderConfig: + providers_by_name: dict[str, ProviderConfig] = {} + for provider in _validated_payloads(snapshot.providers, ProviderConfig): + providers_by_name.setdefault(provider.name, provider) + + models = _validated_payloads(snapshot.models, ModelConfig) + + for model_alias in (active_model, DEFAULT_ACTIVE_MODEL): + for model in models: + if model.alias != model_alias: + continue + if provider := providers_by_name.get(model.provider): + return provider + + for model in models: + if provider := providers_by_name.get(model.provider): + return provider + + if len(providers_by_name) == 1: + return next(iter(providers_by_name.values())) + + return DEFAULT_PROVIDERS[0] + + +@dataclass(frozen=True) +class OnboardingContext: + provider: ProviderConfig + + @property + def supports_browser_sign_in(self) -> bool: + return self.provider.supports_browser_sign_in + + @classmethod + def from_config(cls, config: VibeConfig) -> OnboardingContext: + return cls(provider=config.get_provider_for_model(config.get_active_model())) + + @classmethod + def load(cls, **overrides: Any) -> OnboardingContext: + try: + snapshot = _OnboardingSnapshot.model_validate( + _build_onboarding_snapshot_payload(**overrides) + ) + return cls( + provider=_resolve_provider( + active_model=snapshot.active_model, snapshot=snapshot + ) + ) + except (RuntimeError, ValidationError, ValueError): + logger.warning( + "Onboarding config fallback activated; using defaults", exc_info=True + ) + return cls.from_config(VibeConfig.model_construct()) diff --git a/vibe/setup/onboarding/screens/api_key.py b/vibe/setup/onboarding/screens/api_key.py index ae18e2a..8e1d8e1 100644 --- a/vibe/setup/onboarding/screens/api_key.py +++ b/vibe/setup/onboarding/screens/api_key.py @@ -13,11 +13,12 @@ from textual.widgets import Input, Link, Static from vibe.cli.clipboard import copy_selection_to_clipboard from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic -from vibe.core.config import VibeConfig +from vibe.core.config import DEFAULT_PROVIDERS, ProviderConfig, VibeConfig from vibe.core.paths import GLOBAL_ENV_FILE from vibe.core.telemetry.send import TelemetryClient from vibe.core.types import Backend from vibe.setup.onboarding.base import OnboardingScreen +from vibe.setup.onboarding.context import OnboardingContext PROVIDER_HELP = { "mistral": ("https://console.mistral.ai/codestral/cli", "Mistral AI Studio") @@ -32,6 +33,42 @@ def _save_api_key_to_env_file(env_key: str, api_key: str) -> None: set_key(GLOBAL_ENV_FILE.path, env_key, api_key) +def persist_api_key(provider: ProviderConfig, api_key: str) -> str: + env_key = provider.api_key_env_var + if not env_key: + return "env_var_error:" + try: + os.environ[env_key] = api_key + except ValueError: + return f"env_var_error:{env_key}" + try: + _save_api_key_to_env_file(env_key, api_key) + except (OSError, ValueError) as err: + return f"save_error:{err}" + if provider.backend == Backend.MISTRAL: + try: + telemetry = TelemetryClient(config_getter=VibeConfig) + telemetry.send_onboarding_api_key_added() + except Exception: + pass + return "completed" + + +def _get_mistral_provider() -> ProviderConfig: + return next( + provider for provider in DEFAULT_PROVIDERS if provider.name == "mistral" + ) + + +def _resolve_onboarding_provider( + provider: ProviderConfig | None = None, +) -> ProviderConfig: + resolved_provider = provider or OnboardingContext.load().provider + if resolved_provider.api_key_env_var: + return resolved_provider + return _get_mistral_provider() + + class ApiKeyScreen(OnboardingScreen): BINDINGS: ClassVar[list[BindingType]] = [ Binding("ctrl+c", "cancel", "Cancel", show=False), @@ -40,11 +77,9 @@ class ApiKeyScreen(OnboardingScreen): NEXT_SCREEN = None - def __init__(self) -> None: + def __init__(self, provider: ProviderConfig | None = None) -> None: super().__init__() - config = VibeConfig.model_construct() - active_model = config.get_active_model() - self.provider = config.get_provider_for_model(active_model) + self.provider = _resolve_onboarding_provider(provider) def _compose_provider_link(self, provider_name: str) -> ComposeResult: if self.provider.name not in PROVIDER_HELP: @@ -124,20 +159,7 @@ class ApiKeyScreen(OnboardingScreen): self._save_and_finish(event.value) def _save_and_finish(self, api_key: str) -> None: - env_key = self.provider.api_key_env_var - os.environ[env_key] = api_key - try: - _save_api_key_to_env_file(env_key, api_key) - except OSError as err: - self.app.exit(f"save_error:{err}") - return - if self.provider.backend == Backend.MISTRAL: - try: - telemetry = TelemetryClient(config_getter=VibeConfig) - telemetry.send_onboarding_api_key_added() - except Exception: - pass # Telemetry is fire-and-forget; don't fail onboarding - self.app.exit("completed") + self.app.exit(persist_api_key(self.provider, api_key)) def on_mouse_up(self, event: MouseUp) -> None: copy_selection_to_clipboard(self.app) diff --git a/vibe/setup/trusted_folders/trust_folder_dialog.py b/vibe/setup/trusted_folders/trust_folder_dialog.py index 6308975..bafe5f6 100644 --- a/vibe/setup/trusted_folders/trust_folder_dialog.py +++ b/vibe/setup/trusted_folders/trust_folder_dialog.py @@ -6,7 +6,7 @@ from typing import Any, ClassVar from textual import events from textual.app import App, ComposeResult from textual.binding import Binding, BindingType -from textual.containers import CenterMiddle, Horizontal +from textual.containers import Center, CenterMiddle, Horizontal, VerticalScroll from textual.message import Message from textual.widgets import Static @@ -38,46 +38,77 @@ class TrustFolderDialog(CenterMiddle): class Untrusted(Message): pass - def __init__(self, folder_path: Path, **kwargs: Any) -> None: + def __init__( + self, folder_path: Path, detected_files: list[str], **kwargs: Any + ) -> None: super().__init__(**kwargs) self.folder_path = folder_path + self.detected_files = detected_files self.selected_option = 0 self.option_widgets: list[Static] = [] + def _compose_scroll_content(self) -> ComposeResult: + why_content = ( + "Files here can modify AI behavior. Malicious " + "configs may exfiltrate data, run destructive " + "commands, or silently alter your code." + ) + with Center(classes="trust-dialog-section-center"): + yield NoMarkupStatic( + why_content, + id="trust-dialog-warning", + classes="trust-dialog-section-content", + ) + + if self.detected_files: + yield NoMarkupStatic( + "Detected configuration files\n", classes="trust-dialog-section-header" + ) + file_list = "\n".join(f"\u2022 {f}" for f in self.detected_files) + with Center(classes="trust-dialog-section-center"): + yield NoMarkupStatic( + file_list, + id="trust-dialog-files", + classes="trust-dialog-section-content", + ) + def compose(self) -> ComposeResult: - with CenterMiddle(id="trust-dialog"): - yield NoMarkupStatic( - "⚠ Trust this folder and all its subfolders?", id="trust-dialog-title" - ) - yield NoMarkupStatic( - str(self.folder_path), - id="trust-dialog-path", - classes="trust-dialog-path", - ) - yield NoMarkupStatic( - "Files that can modify your Mistral Vibe setup were found here. Do you trust this folder and all its subfolders?", - id="trust-dialog-message", - classes="trust-dialog-message", - ) + with CenterMiddle(id="trust-dialog-container"): + with CenterMiddle(id="trust-dialog"): + yield NoMarkupStatic("Trust this folder?", id="trust-dialog-title") + yield NoMarkupStatic( + str(self.folder_path), + id="trust-dialog-path", + classes="trust-dialog-path", + ) - with Horizontal(id="trust-options-container"): - options = ["Yes", "No"] - for idx, text in enumerate(options): - widget = NoMarkupStatic( - f" {idx + 1}. {text}", classes="trust-option" - ) - self.option_widgets.append(widget) - yield widget + with VerticalScroll(id="trust-dialog-content"): + yield from self._compose_scroll_content() - yield NoMarkupStatic( - "← → navigate Enter select", classes="trust-dialog-help" - ) + yield NoMarkupStatic( + "Only trust folders you fully control", + id="trust-dialog-footer-warning", + classes="trust-dialog-footer-warning", + ) - yield NoMarkupStatic( - f"Setting will be saved in: {TRUSTED_FOLDERS_FILE.path}", - id="trust-dialog-save-info", - classes="trust-dialog-save-info", - ) + with Horizontal(id="trust-options-container"): + options = ["Yes, trust this folder", "No, ignore config files"] + for idx, text in enumerate(options): + widget = NoMarkupStatic( + f" {idx + 1}. {text}", classes="trust-option" + ) + self.option_widgets.append(widget) + yield widget + + yield NoMarkupStatic( + "← → navigate Enter select", classes="trust-dialog-help" + ) + + yield NoMarkupStatic( + f"Setting will be saved in: {TRUSTED_FOLDERS_FILE.path}", + id="trust-dialog-save-info", + classes="trust-dialog-save-info", + ) async def on_mount(self) -> None: self.selected_option = 1 # Default to "No" @@ -85,7 +116,7 @@ class TrustFolderDialog(CenterMiddle): self.focus() def _update_options(self) -> None: - options = ["Yes", "No"] + options = ["Yes, trust this folder", "No, ignore config files"] if len(self.option_widgets) != len(options): return @@ -146,9 +177,12 @@ class TrustFolderApp(App): Binding("ctrl+c", "quit_without_saving", "Quit", show=False, priority=True), ] - def __init__(self, folder_path: Path, **kwargs: Any) -> None: + def __init__( + self, folder_path: Path, detected_files: list[str], **kwargs: Any + ) -> None: super().__init__(**kwargs) self.folder_path = folder_path + self.detected_files = detected_files self._result: bool | None = None self._quit_without_saving = False @@ -156,7 +190,7 @@ class TrustFolderApp(App): self.theme = "textual-ansi" def compose(self) -> ComposeResult: - yield TrustFolderDialog(self.folder_path) + yield TrustFolderDialog(self.folder_path, self.detected_files) def action_quit_without_saving(self) -> None: self._quit_without_saving = True @@ -177,6 +211,6 @@ class TrustFolderApp(App): return self._result -def ask_trust_folder(folder_path: Path) -> bool | None: - app = TrustFolderApp(folder_path) +def ask_trust_folder(folder_path: Path, detected_files: list[str]) -> bool | None: + app = TrustFolderApp(folder_path, detected_files) return app.run_trust_dialog() diff --git a/vibe/setup/trusted_folders/trust_folder_dialog.tcss b/vibe/setup/trusted_folders/trust_folder_dialog.tcss index 7b7ab40..51e188a 100644 --- a/vibe/setup/trusted_folders/trust_folder_dialog.tcss +++ b/vibe/setup/trusted_folders/trust_folder_dialog.tcss @@ -3,14 +3,25 @@ Screen { background: transparent 80%; } + #trust-dialog { - width: 70; - max-width: 90%; - min-width: 50; - height: auto; + max-width: 70; + overflow-y: scroll; border: round ansi_bright_black; background: transparent; + height:auto; + max-height: 1fr; padding: 1; + padding-left:5; + padding-right:5; + scrollbar-size: 1 1; +} + +#trust-dialog-content { + width: 100%; + height: auto; + min-height: 3; + max-height: 10; } #trust-dialog-title { @@ -31,15 +42,42 @@ Screen { margin-bottom: 1; } -#trust-dialog-message { +.trust-dialog-section-header { width: 100%; height: auto; color: ansi_default; text-align: center; text-wrap: wrap; + padding: 0 2; +} + +.trust-dialog-section-center { + width: 100%; + height: auto; margin-bottom: 1; } +.trust-dialog-section-content { + width: auto; + max-width: 100%; + height: auto; + color: ansi_default; + text-align: center; + text-overflow: fold; +} + +.trust-dialog-footer-warning { + width: 100%; + height: auto; + color: ansi_yellow; + text-align: center; + text-style: bold; + border-top: solid ansi_bright_black; + border-bottom: solid ansi_bright_black; + margin-bottom: 1; + padding: 0 1; +} + #trust-options-container { width: 100%; height: auto; @@ -79,5 +117,4 @@ Screen { text-align: center; text-style: italic; text-wrap: wrap; - margin-bottom: 1; } diff --git a/vibe/whats_new.md b/vibe/whats_new.md index 35a5825..e01d018 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -1,4 +1,4 @@ -# What's new in v2.7.4 -- **MCP command**: New `/mcp` command to display MCP servers and their status -- **Manual command output**: Manual `!` commands now forward their output to the agent as context -- **Console view**: New `ctrl+\` keybind to toggle logs display. +# What's new in v2.7.5 + +- **Trust dialog**: Display detected files and LLM risks in the trust folder dialog +- **Faster startup**: Deferred MCP and git I/O to a background thread for faster CLI startup