Co-authored-by: Bastien <bastien.baret@gmail.com>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Julien Legrand <72564015+JulienLGRD@users.noreply.github.com>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-04-14 10:33:15 +02:00 committed by GitHub
parent e9a9217cc8
commit e1a25caa52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 2830 additions and 594 deletions

3
.gitignore vendored
View file

@ -200,5 +200,6 @@ result-*
tests/playground/* tests/playground/*
. .
# Profiler HTML reports (generated by vibe.cli.profiler) # Profiler HTML/TXT reports (generated by vibe.cli.profiler)
*-profile.html *-profile.html
*-profile.txt

2
.vscode/launch.json vendored
View file

@ -1,5 +1,5 @@
{ {
"version": "2.7.4", "version": "2.7.5",
"configurations": [ "configurations": [
{ {
"name": "ACP Server", "name": "ACP Server",

View file

@ -134,6 +134,20 @@ guidelines:
- uv run script.py to run a script within the uv environment - 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 - 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)" - title: "Imports in Cursor (no Pylance)"
description: > description: >
Cursor's built-in Pyright does not offer the "Add import" quick fix (Ctrl+.). To add a missing import: Cursor's built-in Pyright does not offer the "Add import" quick fix (Ctrl+.). To add a missing import:

View file

@ -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/), 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). 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 ## [2.7.4] - 2026-04-09
### Added ### Added

View file

@ -1,7 +1,7 @@
id = "mistral-vibe" id = "mistral-vibe"
name = "Mistral Vibe" name = "Mistral Vibe"
description = "Mistral's open-source coding assistant" description = "Mistral's open-source coding assistant"
version = "2.7.4" version = "2.7.5"
schema_version = 1 schema_version = 1
authors = ["Mistral AI"] authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe" repository = "https://github.com/mistralai/mistral-vibe"
@ -11,25 +11,25 @@ name = "Mistral Vibe"
icon = "./icons/mistral_vibe.svg" icon = "./icons/mistral_vibe.svg"
[agent_servers.mistral-vibe.targets.darwin-aarch64] [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" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64] [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" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64] [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" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64] [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" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-aarch64] [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" cmd = "./vibe-acp.exe"
[agent_servers.mistral-vibe.targets.windows-x86_64] [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" cmd = "./vibe-acp.exe"

View file

@ -1,6 +1,6 @@
[project] [project]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.7.4" version = "2.7.5"
description = "Minimal CLI coding agent by Mistral" description = "Minimal CLI coding agent by Mistral"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@ -30,11 +30,13 @@ dependencies = [
"agent-client-protocol==0.9.0", "agent-client-protocol==0.9.0",
"anyio>=4.12.0", "anyio>=4.12.0",
"cachetools>=5.5.0", "cachetools>=5.5.0",
"charset-normalizer>=3.4.4",
"cryptography>=44.0.0,<=46.0.3", "cryptography>=44.0.0,<=46.0.3",
"gitpython>=3.1.46", "gitpython>=3.1.46",
"giturlparse>=0.14.0", "giturlparse>=0.14.0",
"google-auth>=2.0.0", "google-auth>=2.0.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"jsonpatch>=1.33",
"keyring>=25.6.0", "keyring>=25.6.0",
"markdownify>=1.2.2", "markdownify>=1.2.2",
"mcp>=1.14.0", "mcp>=1.14.0",
@ -53,7 +55,7 @@ dependencies = [
"requests>=2.20.0", "requests>=2.20.0",
"rich>=14.0.0", "rich>=14.0.0",
"sounddevice>=0.5.1", "sounddevice>=0.5.1",
"textual>=8.1.1", "textual>=8.2.1",
"textual-speedups>=0.2.1", "textual-speedups>=0.2.1",
"tomli-w>=1.2.0", "tomli-w>=1.2.0",
"tree-sitter>=0.25.2", "tree-sitter>=0.25.2",
@ -171,10 +173,12 @@ order-by-type = true
required-imports = ["from __future__ import annotations"] required-imports = ["from __future__ import annotations"]
[tool.ruff.lint.pylint] [tool.ruff.lint.pylint]
max-public-methods = 25
max-positional-args = 10
max-statements = 50 max-statements = 50
max-branches = 15 max-branches = 15
max-locals = 15 max-locals = 15
max-args = 9 max-args = 10
max-returns = 6 max-returns = 6
max-nested-blocks = 5 max-nested-blocks = 5

View file

@ -28,7 +28,7 @@ class TestACPInitialize:
session_capabilities=SessionCapabilities(list=SessionListCapabilities()), session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
) )
assert response.agent_info == Implementation( 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 == [] assert response.auth_methods == []
@ -52,7 +52,7 @@ class TestACPInitialize:
session_capabilities=SessionCapabilities(list=SessionListCapabilities()), session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
) )
assert response.agent_info == Implementation( 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 assert response.auth_methods is not None

View file

@ -25,7 +25,7 @@ class TestEdit:
def test_returns_modified_content(self) -> None: def test_returns_modified_content(self) -> None:
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True): with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
with patch("subprocess.run") as mock_run: 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"): with patch("pathlib.Path.unlink"):
editor = ExternalEditor() editor = ExternalEditor()
result = editor.edit("original") result = editor.edit("original")
@ -35,7 +35,7 @@ class TestEdit:
def test_returns_none_when_content_unchanged(self) -> None: def test_returns_none_when_content_unchanged(self) -> None:
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True): with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
with patch("subprocess.run"): 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"): with patch("pathlib.Path.unlink"):
editor = ExternalEditor() editor = ExternalEditor()
result = editor.edit("same") result = editor.edit("same")
@ -44,7 +44,7 @@ class TestEdit:
def test_strips_trailing_whitespace(self) -> None: def test_strips_trailing_whitespace(self) -> None:
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True): with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
with patch("subprocess.run"): 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"): with patch("pathlib.Path.unlink"):
editor = ExternalEditor() editor = ExternalEditor()
result = editor.edit("original") result = editor.edit("original")
@ -53,7 +53,7 @@ class TestEdit:
def test_handles_editor_with_args(self) -> None: def test_handles_editor_with_args(self) -> None:
with patch.dict("os.environ", {"VISUAL": "code --wait"}, clear=True): with patch.dict("os.environ", {"VISUAL": "code --wait"}, clear=True):
with patch("subprocess.run") as mock_run: 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"): with patch("pathlib.Path.unlink"):
editor = ExternalEditor() editor = ExternalEditor()
editor.edit("original") editor.edit("original")

View file

@ -39,6 +39,8 @@ def get_base_config() -> dict[str, Any]:
"name": "mistral", "name": "mistral",
"api_base": "https://api.mistral.ai/v1", "api_base": "https://api.mistral.ai/v1",
"api_key_env_var": "MISTRAL_API_KEY", "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", "backend": "mistral",
} }
], ],

View file

@ -1,13 +1,20 @@
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path from pathlib import Path
import tomllib import tomllib
from typing import Literal, TypedDict, Unpack
import pytest import pytest
import tomli_w import tomli_w
from tests.conftest import build_test_vibe_config 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 ( from vibe.core.config.harness_files import (
HarnessFilesManager, HarnessFilesManager,
init_harness_files_manager, 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.paths import VIBE_HOME
from vibe.core.trusted_folders import trusted_folders_manager 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: class TestResolveConfigFile:
@ -86,6 +153,51 @@ class TestResolveConfigFile:
assert mgr.config_file == VIBE_HOME.path / "config.toml" 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: class TestMigrateRemovesFindFromBashAllowlist:
def test_removes_find_from_config_file( def test_removes_find_from_config_file(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@ -177,6 +289,472 @@ class TestAutoCompactThresholdFallback:
assert cfg2.get_active_model().auto_compact_threshold == 75_000 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: class TestCompactionModel:
def test_get_compaction_model_returns_active_when_unset(self) -> None: def test_get_compaction_model_returns_active_when_unset(self) -> None:
cfg = build_test_vibe_config() cfg = build_test_vibe_config()
@ -224,3 +802,68 @@ class TestCompactionModel:
cfg = build_test_vibe_config() cfg = build_test_vibe_config()
dumped = cfg.model_dump() dumped = cfg.model_dump()
assert "compaction_model" not in dumped assert "compaction_model" not in dumped
class TestGetMistralProvider:
def test_returns_active_provider_when_it_is_mistral(self) -> None:
cfg = build_test_vibe_config()
provider = cfg.get_mistral_provider()
active = cfg.get_provider_for_model(cfg.get_active_model())
assert provider is active
assert provider is not None
assert provider.backend == Backend.MISTRAL
def test_falls_back_to_first_mistral_provider_when_active_is_not_mistral(
self,
) -> None:
mistral_provider = ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)
llamacpp_provider = ProviderConfig(
name="llamacpp", api_base="http://127.0.0.1:8080/v1", api_key_env_var=""
)
llamacpp_model = ModelConfig(
name="llama-local", provider="llamacpp", alias="llama-local"
)
cfg = build_test_vibe_config(
providers=[llamacpp_provider, mistral_provider],
models=[llamacpp_model],
active_model="llama-local",
)
provider = cfg.get_mistral_provider()
assert provider is mistral_provider
def test_returns_none_when_no_mistral_provider(self) -> None:
llamacpp_provider = ProviderConfig(
name="llamacpp", api_base="http://127.0.0.1:8080/v1", api_key_env_var=""
)
llamacpp_model = ModelConfig(
name="llama-local", provider="llamacpp", alias="llama-local"
)
cfg = build_test_vibe_config(
providers=[llamacpp_provider],
models=[llamacpp_model],
active_model="llama-local",
)
assert cfg.get_mistral_provider() is None
def test_falls_back_to_iterating_when_active_model_is_misconfigured(self) -> None:
mistral_provider = ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)
llamacpp_model = ModelConfig(
name="llama-local", provider="llamacpp", alias="llama-local"
)
cfg = build_test_vibe_config(
providers=[mistral_provider],
models=[llamacpp_model],
active_model="llama-local",
)
provider = cfg.get_mistral_provider()
assert provider is mistral_provider

View file

@ -5,131 +5,164 @@ from pathlib import Path
from vibe.core.paths._local_config_walk import ( from vibe.core.paths._local_config_walk import (
_MAX_DIRS, _MAX_DIRS,
WALK_MAX_DEPTH, WALK_MAX_DEPTH,
has_config_dirs_nearby, walk_local_config_dirs,
walk_local_config_dirs_all,
) )
class TestBoundedWalk: class TestWalkTools:
def test_finds_config_at_root(self, tmp_path: Path) -> None: def test_finds_config_at_root(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True) (tmp_path / ".vibe" / "tools").mkdir(parents=True)
tools, skills, agents = walk_local_config_dirs_all(tmp_path) result = walk_local_config_dirs(tmp_path)
assert tmp_path / ".vibe" / "tools" in tools assert tmp_path.resolve() / ".vibe" / "tools" in result.tools
def test_finds_config_within_depth_limit(self, tmp_path: Path) -> None: def test_finds_config_within_depth_limit(self, tmp_path: Path) -> None:
nested = tmp_path nested = tmp_path
for i in range(WALK_MAX_DEPTH): for i in range(WALK_MAX_DEPTH):
nested = nested / f"level{i}" nested = nested / f"level{i}"
(nested / ".vibe" / "skills").mkdir(parents=True) (nested / ".vibe" / "skills").mkdir(parents=True)
_, skills, _ = walk_local_config_dirs_all(tmp_path) result = walk_local_config_dirs(tmp_path)
assert nested / ".vibe" / "skills" in skills assert nested.resolve() / ".vibe" / "skills" in result.skills
def test_does_not_find_config_beyond_depth_limit(self, tmp_path: Path) -> None: def test_does_not_find_config_beyond_depth_limit(self, tmp_path: Path) -> None:
nested = tmp_path nested = tmp_path
for i in range(WALK_MAX_DEPTH + 1): for i in range(WALK_MAX_DEPTH + 1):
nested = nested / f"level{i}" nested = nested / f"level{i}"
(nested / ".vibe" / "tools").mkdir(parents=True) (nested / ".vibe" / "tools").mkdir(parents=True)
tools, skills, agents = walk_local_config_dirs_all(tmp_path) result = walk_local_config_dirs(tmp_path)
assert not tools assert not result.tools
assert not skills assert not result.skills
assert not agents assert not result.agents
def test_respects_dir_count_limit(self, tmp_path: Path) -> None: 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): for i in range(_MAX_DIRS + 10):
(tmp_path / f"dir{i:05d}").mkdir() (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) (tmp_path / "zzz_last" / ".vibe" / "tools").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
tools, _, _ = walk_local_config_dirs_all(tmp_path) assert isinstance(result.tools, tuple)
# 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)
def test_skips_ignored_directories(self, tmp_path: Path) -> None: def test_skips_ignored_directories(self, tmp_path: Path) -> None:
(tmp_path / "node_modules" / ".vibe" / "tools").mkdir(parents=True) (tmp_path / "node_modules" / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / ".vibe" / "tools").mkdir(parents=True) (tmp_path / ".vibe" / "tools").mkdir(parents=True)
tools, _, _ = walk_local_config_dirs_all(tmp_path) result = walk_local_config_dirs(tmp_path)
assert tools == (tmp_path / ".vibe" / "tools",) assert result.tools == (tmp_path.resolve() / ".vibe" / "tools",)
def test_skips_dot_directories(self, tmp_path: Path) -> None: def test_skips_dot_directories(self, tmp_path: Path) -> None:
(tmp_path / ".hidden" / ".vibe" / "tools").mkdir(parents=True) (tmp_path / ".hidden" / ".vibe" / "tools").mkdir(parents=True)
tools, _, _ = walk_local_config_dirs_all(tmp_path) result = walk_local_config_dirs(tmp_path)
assert not tools assert not result.tools
def test_preserves_alphabetical_ordering(self, tmp_path: Path) -> None: def test_preserves_alphabetical_ordering(self, tmp_path: Path) -> None:
(tmp_path / "bbb" / ".vibe" / "tools").mkdir(parents=True) (tmp_path / "bbb" / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / "aaa" / ".vibe" / "tools").mkdir(parents=True) (tmp_path / "aaa" / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / ".vibe" / "tools").mkdir(parents=True) (tmp_path / ".vibe" / "tools").mkdir(parents=True)
tools, _, _ = walk_local_config_dirs_all(tmp_path) result = walk_local_config_dirs(tmp_path)
assert tools == ( resolved = tmp_path.resolve()
tmp_path / ".vibe" / "tools", assert result.tools == (
tmp_path / "aaa" / ".vibe" / "tools", resolved / ".vibe" / "tools",
tmp_path / "bbb" / ".vibe" / "tools", resolved / "aaa" / ".vibe" / "tools",
resolved / "bbb" / ".vibe" / "tools",
) )
def test_finds_agents_skills(self, tmp_path: Path) -> None: def test_finds_agents_skills(self, tmp_path: Path) -> None:
(tmp_path / ".agents" / "skills").mkdir(parents=True) (tmp_path / ".agents" / "skills").mkdir(parents=True)
_, skills, _ = walk_local_config_dirs_all(tmp_path) result = walk_local_config_dirs(tmp_path)
assert tmp_path / ".agents" / "skills" in skills assert tmp_path.resolve() / ".agents" / "skills" in result.skills
def test_finds_all_config_types(self, tmp_path: Path) -> None: def test_finds_all_config_types(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True) (tmp_path / ".vibe" / "tools").mkdir(parents=True)
(tmp_path / ".vibe" / "skills").mkdir(parents=True) (tmp_path / ".vibe" / "skills").mkdir(parents=True)
(tmp_path / ".vibe" / "agents").mkdir(parents=True) (tmp_path / ".vibe" / "agents").mkdir(parents=True)
(tmp_path / ".agents" / "skills").mkdir(parents=True) (tmp_path / ".agents" / "skills").mkdir(parents=True)
tools, skills, agents = walk_local_config_dirs_all(tmp_path) result = walk_local_config_dirs(tmp_path)
assert tmp_path / ".vibe" / "tools" in tools resolved = tmp_path.resolve()
assert tmp_path / ".vibe" / "skills" in skills assert resolved / ".vibe" / "tools" in result.tools
assert tmp_path / ".vibe" / "agents" in agents assert resolved / ".vibe" / "skills" in result.skills
assert tmp_path / ".agents" / "skills" in skills assert resolved / ".vibe" / "agents" in result.agents
assert resolved / ".agents" / "skills" in result.skills
class TestHasConfigDirsNearby: class TestWalkConfigDirs:
def test_returns_true_when_vibe_tools_exist(self, tmp_path: Path) -> None: def test_finds_vibe_with_tools(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True) (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) (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) (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: def test_ignores_empty_vibe_dir(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:
(tmp_path / ".vibe").mkdir() (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) (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) (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) (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: def test_custom_depth(self, tmp_path: Path) -> None:
(tmp_path / "a" / "b" / "c" / "d" / "e" / ".vibe" / "tools").mkdir(parents=True) (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: def test_finds_match_among_many_dirs(self, tmp_path: Path) -> None:
# Create many dirs but put config early; function should return quickly
(tmp_path / ".vibe" / "tools").mkdir(parents=True) (tmp_path / ".vibe" / "tools").mkdir(parents=True)
for i in range(100): for i in range(100):
(tmp_path / f"dir{i}").mkdir() (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: def test_skips_ignored_directories(self, tmp_path: Path) -> None:
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True) (tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
assert has_config_dirs_nearby(tmp_path) is False result = walk_local_config_dirs(tmp_path)
assert result.config_dirs == ()
def test_finds_vibe_with_prompts(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "prompts").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" in result.config_dirs
def test_finds_vibe_with_config_toml(self, tmp_path: Path) -> None:
(tmp_path / ".vibe").mkdir()
(tmp_path / ".vibe" / "config.toml").write_text("")
result = walk_local_config_dirs(tmp_path)
assert tmp_path.resolve() / ".vibe" in result.config_dirs
def test_finds_multiple_config_dirs(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
(tmp_path / ".agents" / "skills").mkdir(parents=True)
(tmp_path / "sub" / ".vibe" / "tools").mkdir(parents=True)
result = walk_local_config_dirs(tmp_path)
resolved = tmp_path.resolve()
assert resolved / ".vibe" in result.config_dirs
assert resolved / ".agents" in result.config_dirs
assert resolved / "sub" / ".vibe" in result.config_dirs

View file

@ -724,3 +724,46 @@ def test_working_completed_with_tool_call_id_emits_error_result() -> None:
assert len(result_events) == 1 assert len(result_events) == 1
assert result_events[0].error is not None assert result_events[0].error is not None
assert result_events[0].tool_call_id == "call-write-err" assert result_events[0].tool_call_id == "call-write-err"
def test_json_patch_with_array_index_preserves_list_structure() -> None:
loop = _make_loop()
started = _started(
"msg-1",
"assistant_message",
{"contentChunks": [{"type": "text", "text": "Hello"}]},
)
in_progress = _in_progress(
"msg-1",
"assistant_message",
[JSONPatchReplace(path="/contentChunks/0/text", value="Hello world")],
)
loop._consume_workflow_event(started)
progress_events = loop._consume_workflow_event(in_progress)
assert len(progress_events) == 1
assert isinstance(progress_events[0], AssistantEvent)
assert progress_events[0].content == " world"
def test_steer_input_events_are_suppressed() -> None:
loop = _make_loop()
steer_started = _started(
"steer-1",
"wait_for_input",
{"input_schema": {"title": "ChatInput"}, "label": "Send a message to steer..."},
)
steer_completed = _completed(
"steer-1",
"wait_for_input",
{
"input_schema": {"title": "ChatInput"},
"label": "Send a message to steer...",
"input": None,
},
)
assert loop._consume_workflow_event(steer_started) == []
assert loop._consume_workflow_event(steer_completed) == []
assert loop._translator.pending_input_request is None

View file

@ -0,0 +1,34 @@
from __future__ import annotations
from pathlib import Path
import pytest
from tests.mock.utils import collect_result
from vibe.core.tools.base import BaseToolState
from vibe.core.tools.builtins.search_replace import (
SearchReplace,
SearchReplaceArgs,
SearchReplaceConfig,
)
@pytest.mark.asyncio
async def test_search_replace_rewrites_with_detected_encoding(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
path = tmp_path / "utf16.txt"
original = "line one café\nline two été\n"
path.write_bytes(original.encode("utf-16"))
tool = SearchReplace(
config_getter=lambda: SearchReplaceConfig(), state=BaseToolState()
)
patch = "<<<<<<< SEARCH\nline one café\n=======\nLINE ONE CAFÉ\n>>>>>>> REPLACE"
await collect_result(
tool.run(SearchReplaceArgs(file_path=str(path), content=patch))
)
assert path.read_bytes().startswith(b"\xff\xfe")
assert path.read_text(encoding="utf-16") == "LINE ONE CAFÉ\nline two été\n"

View file

@ -10,7 +10,7 @@ from tests.conftest import build_test_vibe_config
from tests.stubs.fake_tool import FakeTool, FakeToolArgs from tests.stubs.fake_tool import FakeTool, FakeToolArgs
from vibe.core.agent_loop import ToolDecision, ToolExecutionResponse from vibe.core.agent_loop import ToolDecision, ToolExecutionResponse
from vibe.core.llm.format import ResolvedToolCall 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.tools.base import BaseTool, ToolPermission
from vibe.core.types import Backend from vibe.core.types import Backend
from vibe.core.utils import get_user_agent from vibe.core.utils import get_user_agent
@ -44,6 +44,13 @@ def _run_telemetry_tasks() -> None:
class TestTelemetryClient: 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( def test_send_telemetry_event_does_nothing_when_api_key_is_none(
self, monkeypatch: pytest.MonkeyPatch self, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
@ -104,7 +111,7 @@ class TestTelemetryClient:
await client.aclose() await client.aclose()
mock_post.assert_called_once_with( mock_post.assert_called_once_with(
DATALAKE_EVENTS_URL, "https://api.mistral.ai/v1/datalake/events",
json={"event": "vibe.test_event", "properties": {"key": "value"}}, json={"event": "vibe.test_event", "properties": {"key": "value"}},
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
@ -302,7 +309,7 @@ class TestTelemetryClient:
await client.aclose() await client.aclose()
mock_post.assert_called_once_with( mock_post.assert_called_once_with(
DATALAKE_EVENTS_URL, "https://api.mistral.ai/v1/datalake/events",
json={ json={
"event": "vibe.test_event", "event": "vibe.test_event",
"properties": {"session_id": session_id, "key": "value"}, "properties": {"session_id": session_id, "key": "value"},
@ -336,7 +343,7 @@ class TestTelemetryClient:
await client.aclose() await client.aclose()
mock_post.assert_called_once_with( mock_post.assert_called_once_with(
DATALAKE_EVENTS_URL, "https://api.mistral.ai/v1/datalake/events",
json={"event": "vibe.test_event", "properties": {"key": "value"}}, json={"event": "vibe.test_event", "properties": {"key": "value"}},
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
@ -414,3 +421,109 @@ class TestTelemetryClient:
assert len(telemetry_events) == 1 assert len(telemetry_events) == 1
assert "correlation_id" not in telemetry_events[0] assert "correlation_id" not in telemetry_events[0]
def test_telemetry_url_custom_provider_config(self) -> None:
from vibe.core.config import ProviderConfig
from vibe.core.types import Backend
custom_api_base = "https://api.custom.host/v2"
config = build_test_vibe_config(
enable_telemetry=True,
providers=[
ProviderConfig(
name="mistral",
api_base=custom_api_base,
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)
],
)
client = TelemetryClient(config_getter=lambda: config)
assert (
client._get_telemetry_url(custom_api_base)
== "https://api.custom.host/v1/datalake/events"
)
def test_telemetry_url_preserves_port_in_api_base(self) -> None:
from vibe.core.config import ProviderConfig
from vibe.core.types import Backend
custom_api_base = "http://api.custom.host:8080/v1/"
config = build_test_vibe_config(
enable_telemetry=True,
providers=[
ProviderConfig(
name="mistral",
api_base=custom_api_base,
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)
],
)
client = TelemetryClient(config_getter=lambda: config)
assert (
client._get_telemetry_url(custom_api_base)
== "http://api.custom.host:8080/v1/datalake/events"
)
def test_telemetry_url_falls_back_to_default_when_api_base_malformed(self) -> None:
from vibe.core.config import ProviderConfig
from vibe.core.types import Backend
config = build_test_vibe_config(
enable_telemetry=True,
providers=[
ProviderConfig(
name="mistral",
api_base="not-a-valid-url",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)
],
)
client = TelemetryClient(config_getter=lambda: config)
assert (
client._get_telemetry_url("not-a-valid-url")
== "https://codestral.mistral.ai/v1/datalake/events"
)
def test_is_active_false_when_mistral_provider_exists_but_no_api_key(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
config = build_test_vibe_config(enable_telemetry=True)
env_key = config.get_provider_for_model(
config.get_active_model()
).api_key_env_var
monkeypatch.delenv(env_key, raising=False)
client = TelemetryClient(config_getter=lambda: config)
assert client.is_active() is False
def test_is_active_false_when_no_mistral_provider(self) -> None:
from vibe.core.config import ProviderConfig
config = build_test_vibe_config(
enable_telemetry=True,
providers=[
ProviderConfig(
name="llamacpp",
api_base="http://127.0.0.1:8080/v1",
api_key_env_var="",
)
],
)
client = TelemetryClient(config_getter=lambda: config)
assert client.is_active() is False
def test_is_active_false_when_config_getter_raises_value_error(self) -> None:
def _raise_config_error() -> Any:
raise ValueError("config not ready")
client = TelemetryClient(config_getter=_raise_config_error)
assert client.is_active() is False

View file

@ -129,7 +129,7 @@ class TestGitRepositoryGetInfo:
async def test_raises_when_not_git_repo(self, tmp_path: Path) -> None: async def test_raises_when_not_git_repo(self, tmp_path: Path) -> None:
repo = GitRepository(tmp_path) repo = GitRepository(tmp_path)
with pytest.raises( with pytest.raises(
ServiceTeleportNotSupportedError, match="Not a git repository" ServiceTeleportNotSupportedError, match="Teleport requires a git repository"
): ):
await repo.get_info() await repo.get_info()

View file

@ -185,7 +185,9 @@ class TestTeleportServiceCheckSupported:
self, service: TeleportService self, service: TeleportService
) -> None: ) -> None:
service._git.get_info = AsyncMock( 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): with pytest.raises(ServiceTeleportNotSupportedError):
await service.check_supported() await service.check_supported()

View file

@ -10,8 +10,8 @@ import tomli_w
from vibe.core.paths import AGENTS_MD_FILENAME, TRUSTED_FOLDERS_FILE from vibe.core.paths import AGENTS_MD_FILENAME, TRUSTED_FOLDERS_FILE
from vibe.core.trusted_folders import ( from vibe.core.trusted_folders import (
TrustedFoldersManager, TrustedFoldersManager,
find_trustable_files,
has_agents_md_file, has_agents_md_file,
has_trustable_content,
) )
@ -319,34 +319,69 @@ class TestHasAgentsMdFile:
assert AGENTS_MD_FILENAME == "AGENTS.md" assert AGENTS_MD_FILENAME == "AGENTS.md"
class TestHasTrustableContent: class TestFindTrustableFiles:
def test_returns_true_when_vibe_dir_exists(self, tmp_path: Path) -> None: def test_returns_empty_for_clean_directory(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "skills").mkdir(parents=True) (tmp_path / "src").mkdir()
assert has_trustable_content(tmp_path) is True 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) (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: def test_ignores_empty_vibe_dir(self, tmp_path: Path) -> None:
(tmp_path / AGENTS_MD_FILENAME).write_text("", encoding="utf-8") (tmp_path / ".vibe").mkdir()
assert has_trustable_content(tmp_path) is True assert find_trustable_files(tmp_path) == []
(tmp_path / AGENTS_MD_FILENAME).unlink()
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") (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) (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) (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 self, tmp_path: Path
) -> None: ) -> None:
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True) (tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
assert has_trustable_content(tmp_path) is False assert find_trustable_files(tmp_path) == []
def test_detects_nested_vibe_dir(self, tmp_path: Path) -> None:
(tmp_path / "pkg" / ".vibe" / "tools").mkdir(parents=True)
result = find_trustable_files(tmp_path)
assert "pkg/.vibe/" in result
def test_detects_multiple_files(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
(tmp_path / "AGENTS.md").write_text("# Agent", encoding="utf-8")
(tmp_path / "sub" / ".agents" / "skills").mkdir(parents=True)
result = find_trustable_files(tmp_path)
assert ".vibe/" in result
assert "AGENTS.md" in result
assert "sub/.agents/" in result
def test_no_duplicates_for_root_vibe_dir(self, tmp_path: Path) -> None:
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
result = find_trustable_files(tmp_path)
assert result.count(".vibe/") == 1

View file

@ -5,7 +5,8 @@ from pathlib import Path
import pytest import pytest
from vibe.core.utils import get_server_url_from_api_base 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( @pytest.mark.parametrize(
@ -26,39 +27,90 @@ class TestReadSafe:
def test_reads_utf8(self, tmp_path: Path) -> None: def test_reads_utf8(self, tmp_path: Path) -> None:
f = tmp_path / "hello.txt" f = tmp_path / "hello.txt"
f.write_text("café\n", encoding="utf-8") 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" f = tmp_path / "latin.txt"
# \x81 invalid UTF-8 and undefined in CP1252 → U+FFFD on all platforms # \x81 invalid UTF-8 and undefined in CP1252 → U+FFFD on all platforms
f.write_bytes(b"maf\x81\n") f.write_bytes(b"maf\x81\n")
monkeypatch.setattr(io_utils, "_encoding_from_best_match", lambda _raw: None)
result = read_safe(f) result = read_safe(f)
assert result == "maf<EFBFBD>\n" assert result.text == "maf<EFBFBD>\n"
def test_falls_back_to_detected_encoding(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
f = tmp_path / "utf16.txt"
expected = "hello été\n"
f.write_bytes(expected.encode("utf-16le"))
monkeypatch.setattr(
io_utils.locale, "getpreferredencoding", lambda _do_setlocale: "utf-8"
)
assert read_safe(f).text == expected
def test_raise_on_error_true_utf8_succeeds(self, tmp_path: Path) -> None: def test_raise_on_error_true_utf8_succeeds(self, tmp_path: Path) -> None:
f = tmp_path / "hello.txt" f = tmp_path / "hello.txt"
f.write_text("café\n", encoding="utf-8") 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" f = tmp_path / "bad.txt"
# Invalid UTF-8; with raise_on_error=True we use default encoding (strict), so decode errors propagate # Invalid UTF-8; with raise_on_error=True we use default encoding (strict), so decode errors propagate
f.write_bytes(b"maf\x81\n") f.write_bytes(b"maf\x81\n")
assert read_safe(f, raise_on_error=False) == "maf<EFBFBD>\n" monkeypatch.setattr(io_utils, "_encoding_from_best_match", lambda _raw: None)
assert read_safe(f, raise_on_error=False).text == "maf<EFBFBD>\n"
with pytest.raises(UnicodeDecodeError): with pytest.raises(UnicodeDecodeError):
read_safe(f, raise_on_error=True) read_safe(f, raise_on_error=True)
def test_empty_file(self, tmp_path: Path) -> None: def test_empty_file(self, tmp_path: Path) -> None:
f = tmp_path / "empty.txt" f = tmp_path / "empty.txt"
f.write_bytes(b"") 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: def test_binary_garbage_does_not_raise(self, tmp_path: Path) -> None:
f = tmp_path / "garbage.bin" f = tmp_path / "garbage.bin"
f.write_bytes(bytes(range(256))) f.write_bytes(bytes(range(256)))
result = read_safe(f) result = read_safe(f)
assert isinstance(result, str) assert isinstance(result.text, str)
def test_file_not_found_raises(self, tmp_path: Path) -> None: def test_file_not_found_raises(self, tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
read_safe(tmp_path / "nope.txt") read_safe(tmp_path / "nope.txt")
class TestReadSafeResultEncoding:
def test_reports_utf8_for_plain_utf8_file(self, tmp_path: Path) -> None:
f = tmp_path / "x.txt"
f.write_text("ok\n", encoding="utf-8")
got = read_safe(f)
assert got.text == "ok\n"
assert got.encoding == "utf-8"
@pytest.mark.asyncio
async def test_async_reports_utf16_when_bom_present(self, tmp_path: Path) -> None:
f = tmp_path / "u16.txt"
f.write_bytes("a\n".encode("utf-16"))
got = await read_safe_async(f)
assert got.encoding == "utf-16-le"
# utf-16-le leaves the BOM as U+FEFF in the string (unlike utf-8-sig).
assert got.text == "\ufeffa\n"
class TestReadSafeAsync:
@pytest.mark.asyncio
async def test_raise_on_error_final_utf8_strict_or_replace(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""raise_on_error controls strict vs replace on the last UTF-8 fallback."""
f = tmp_path / "bad.txt"
f.write_bytes(b"maf\x81\n")
monkeypatch.setattr(io_utils, "_encoding_from_best_match", lambda _raw: None)
assert (await read_safe_async(f, raise_on_error=False)).text == "maf<EFBFBD>\n"
with pytest.raises(UnicodeDecodeError):
await read_safe_async(f, raise_on_error=True)

View file

@ -5,12 +5,14 @@ from pathlib import Path
import pytest import pytest
from tests.mock.utils import collect_result
from vibe.core.config.harness_files import ( from vibe.core.config.harness_files import (
init_harness_files_manager, init_harness_files_manager,
reset_harness_files_manager, reset_harness_files_manager,
) )
from vibe.core.tools.builtins.read_file import ( from vibe.core.tools.builtins.read_file import (
ReadFile, ReadFile,
ReadFileArgs,
ReadFileResult, ReadFileResult,
ReadFileState, ReadFileState,
ReadFileToolConfig, ReadFileToolConfig,
@ -37,6 +39,30 @@ def _make_read_file() -> ReadFile:
return ReadFile(config_getter=lambda: ReadFileToolConfig(), state=ReadFileState()) 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: class TestGetResultExtra:
@pytest.mark.usefixtures("_setup_manager") @pytest.mark.usefixtures("_setup_manager")
def test_returns_none_when_no_agents_md(self, tmp_path: Path) -> None: def test_returns_none_when_no_agents_md(self, tmp_path: Path) -> None:

View file

View file

@ -0,0 +1,192 @@
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock
import pytest
from tests.conftest import build_test_vibe_config
from tests.stubs.fake_audio_player import FakeAudioPlayer
from tests.stubs.fake_tts_client import FakeTTSClient
from vibe.cli.narrator_manager import NarratorManager, NarratorState
from vibe.cli.turn_summary import TurnSummaryResult
from vibe.core.tts.tts_client_port import TTSResult
def _make_manager(
*,
narrator_enabled: bool = True,
telemetry_client: MagicMock | None = None,
tts_client: FakeTTSClient | None = None,
) -> tuple[NarratorManager, FakeAudioPlayer]:
config = build_test_vibe_config(narrator_enabled=narrator_enabled)
audio_player = FakeAudioPlayer()
manager = NarratorManager(
config_getter=lambda: config,
audio_player=audio_player,
telemetry_client=telemetry_client,
)
manager._tts_client = tts_client or FakeTTSClient(
result=TTSResult(audio_data=b"fake-audio")
)
return manager, audio_player
def _find_telemetry_calls(mock: MagicMock, event_name: str) -> list[dict[str, object]]:
results: list[dict[str, object]] = []
for call in mock.send_telemetry_event.call_args_list:
if call[0][0] == event_name:
results.append(call[0][1])
return results
class TestTelemetryTracking:
@pytest.mark.asyncio
async def test_requested_event_on_turn_end(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._turn_summary.start_turn("test")
manager.on_turn_end()
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.requested")
assert len(calls) == 1
assert calls[0]["trigger"] == "autoplay_next_message"
assert isinstance(calls[0]["read_aloud_session_id"], str)
assert len(calls[0]["read_aloud_session_id"]) == 36
def test_no_requested_event_when_narrator_disabled(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(
narrator_enabled=False, telemetry_client=mock_telemetry
)
manager.on_turn_end()
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.requested")
assert len(calls) == 0
@pytest.mark.asyncio
async def test_play_started_on_speak(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._turn_summary.start_turn("test")
manager.on_turn_end()
manager._on_turn_summary(
TurnSummaryResult(
summary="Test summary", generation=manager._turn_summary.generation
)
)
await asyncio.sleep(0)
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.play_started")
assert len(calls) == 1
assert calls[0]["speed_selection"] is None
assert isinstance(calls[0]["time_to_first_read_s"], float)
assert calls[0]["time_to_first_read_s"] >= 0.0
@pytest.mark.asyncio
async def test_ended_completed_on_playback_finished(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._turn_summary.start_turn("test")
manager.on_turn_end()
manager._on_turn_summary(
TurnSummaryResult(
summary="Test summary", generation=manager._turn_summary.generation
)
)
await asyncio.sleep(0)
assert manager.state == NarratorState.SPEAKING
manager._on_playback_finished()
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
assert len(calls) == 1
assert calls[0]["status"] == "completed"
assert calls[0]["error_type"] is None
assert calls[0]["speed_selection"] is None
assert isinstance(calls[0]["elapsed_seconds"], float)
assert calls[0]["elapsed_seconds"] >= 0.0
@pytest.mark.asyncio
async def test_ended_error_on_tts_failure(self) -> None:
mock_telemetry = MagicMock()
class FailingTTSClient:
def __init__(self, *_args: object, **_kwargs: object) -> None:
pass
async def speak(self, text: str) -> TTSResult:
raise RuntimeError("TTS failed")
async def close(self) -> None:
pass
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._tts_client = FailingTTSClient()
manager._turn_summary.start_turn("test")
manager.on_turn_end()
manager._on_turn_summary(
TurnSummaryResult(
summary="Test summary", generation=manager._turn_summary.generation
)
)
await asyncio.sleep(0)
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
assert len(calls) == 1
assert calls[0]["status"] == "error"
assert calls[0]["error_type"] == "RuntimeError"
@pytest.mark.asyncio
async def test_ended_canceled_on_cancel(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._turn_summary.start_turn("test")
manager.on_turn_end()
assert manager.state == NarratorState.SUMMARIZING
manager.cancel()
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
assert len(calls) == 1
assert calls[0]["status"] == "canceled"
@pytest.mark.asyncio
async def test_cancel_during_speaking_fires_single_ended_event(self) -> None:
mock_telemetry = MagicMock()
manager, _ = _make_manager(telemetry_client=mock_telemetry)
manager._turn_summary.start_turn("test")
manager.on_turn_end()
manager._on_turn_summary(
TurnSummaryResult(
summary="Test summary", generation=manager._turn_summary.generation
)
)
await asyncio.sleep(0)
assert manager.state == NarratorState.SPEAKING
manager.cancel()
# Simulate the delayed callback that would come from call_soon_threadsafe
manager._on_playback_finished()
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
assert len(calls) == 1
assert calls[0]["status"] == "canceled"
@pytest.mark.asyncio
async def test_no_error_without_telemetry_client(self) -> None:
manager, _ = _make_manager()
manager._turn_summary.start_turn("test")
manager.on_turn_end()
manager._on_turn_summary(
TurnSummaryResult(
summary="Test summary", generation=manager._turn_summary.generation
)
)
await asyncio.sleep(0)
manager._on_playback_finished()

View file

@ -0,0 +1,61 @@
from __future__ import annotations
from vibe.cli.narrator_manager.telemetry import ReadAloudTrackingState
class TestReadAloudTrackingState:
def test_default_state(self) -> None:
state = ReadAloudTrackingState()
assert state.session_id == ""
assert state.request_time == 0.0
assert state.play_start_time == 0.0
def test_reset_generates_session_id(self) -> None:
state = ReadAloudTrackingState()
state.reset()
assert state.session_id != ""
assert len(state.session_id) == 36 # UUID format
def test_reset_generates_unique_session_ids(self) -> None:
state = ReadAloudTrackingState()
state.reset()
first_id = state.session_id
state.reset()
assert state.session_id != first_id
def test_reset_clears_play_start_time(self) -> None:
state = ReadAloudTrackingState()
state.reset()
state.mark_play_started()
assert state.play_start_time > 0.0
state.reset()
assert state.play_start_time == 0.0
def test_mark_play_started(self) -> None:
state = ReadAloudTrackingState()
state.reset()
state.mark_play_started()
assert state.play_start_time > 0.0
def test_time_to_first_read_s(self) -> None:
state = ReadAloudTrackingState()
state.reset()
state.mark_play_started()
ttfr = state.time_to_first_read_s()
assert ttfr >= 0.0
def test_time_to_first_read_s_before_play(self) -> None:
state = ReadAloudTrackingState()
state.reset()
assert state.time_to_first_read_s() == 0.0
def test_elapsed_since_play_s(self) -> None:
state = ReadAloudTrackingState()
state.reset()
state.mark_play_started()
elapsed = state.elapsed_since_play_s()
assert elapsed >= 0.0
def test_elapsed_since_play_s_before_play(self) -> None:
state = ReadAloudTrackingState()
assert state.elapsed_since_play_s() == 0.0

View file

@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import sys import sys
from typing import override from typing import override
@ -25,7 +24,7 @@ def _exit_raiser(code: int = 0) -> None:
def test_exits_on_cancel( def test_exits_on_cancel(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None: ) -> None:
monkeypatch.setattr(sys, "exit", _exit_raiser) monkeypatch.setattr(sys, "exit", _exit_raiser)
@ -38,7 +37,7 @@ def test_exits_on_cancel(
def test_warns_on_save_error( def test_warns_on_save_error(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None: ) -> None:
monkeypatch.setattr(sys, "exit", _exit_raiser) monkeypatch.setattr(sys, "exit", _exit_raiser)
@ -49,8 +48,25 @@ def test_warns_on_save_error(
assert "disk full" in out 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( def test_successfully_completes(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None: ) -> None:
monkeypatch.setattr(sys, "exit", _exit_raiser) monkeypatch.setattr(sys, "exit", _exit_raiser)

View file

@ -1,14 +1,16 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from types import SimpleNamespace
import pytest import pytest
from textual.pilot import Pilot from textual.pilot import Pilot
from textual.widgets import Input from textual.widgets import Input
from vibe.core.config import ProviderConfig
from vibe.core.paths import GLOBAL_ENV_FILE from vibe.core.paths import GLOBAL_ENV_FILE
from vibe.setup.onboarding import OnboardingApp 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( 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") env_contents = GLOBAL_ENV_FILE.path.read_text(encoding="utf-8")
assert "MISTRAL_API_KEY" in env_contents assert "MISTRAL_API_KEY" in env_contents
assert api_key_value in env_contents assert api_key_value in env_contents
def test_api_key_screen_falls_back_to_mistral_for_provider_without_env_key() -> None:
screen = ApiKeyScreen(
provider=ProviderConfig(
name="llamacpp", api_base="http://127.0.0.1:8080/v1", api_key_env_var=""
)
)
assert screen.provider.name == "mistral"
assert screen.provider.api_key_env_var == "MISTRAL_API_KEY"
def test_api_key_screen_keeps_provider_with_explicit_env_key() -> None:
provider = ProviderConfig(
name="custom",
api_base="https://custom.example/v1",
api_key_env_var="CUSTOM_API_KEY",
)
screen = ApiKeyScreen(provider=provider)
assert screen.provider == provider
def test_api_key_screen_uses_mistral_fallback_for_context_without_env_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"vibe.setup.onboarding.screens.api_key.OnboardingContext.load",
lambda: SimpleNamespace(
provider=ProviderConfig(
name="llamacpp", api_base="http://127.0.0.1:8080/v1", api_key_env_var=""
)
),
)
screen = ApiKeyScreen()
assert screen.provider.name == "mistral"
assert screen.provider.api_key_env_var == "MISTRAL_API_KEY"
def test_persist_api_key_returns_save_error_for_invalid_env_var_name() -> None:
provider = ProviderConfig(
name="custom", api_base="https://custom.example/v1", api_key_env_var="BAD=NAME"
)
result = persist_api_key(provider, "secret")
assert result == "env_var_error:BAD=NAME"
def test_persist_api_key_returns_env_var_error_for_empty_env_var_name() -> None:
provider = ProviderConfig(
name="custom", api_base="https://custom.example/v1", api_key_env_var=""
)
result = persist_api_key(provider, "secret")
assert result == "env_var_error:<empty>"

View file

@ -9,7 +9,8 @@ import pytest
from vibe.core.config import SessionLoggingConfig from vibe.core.config import SessionLoggingConfig
from vibe.core.session.session_loader import SessionLoader 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 @pytest.fixture
@ -1008,7 +1009,7 @@ class TestSessionLoaderUTF8Encoding:
session_folder = session_dir / "test_20230101_120000_latin100" session_folder = session_dir / "test_20230101_120000_latin100"
session_folder.mkdir() 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 = { metadata_content = {
"session_id": "latin1-test", "session_id": "latin1-test",
"start_time": "2023-01-01T12:00:00Z", "start_time": "2023-01-01T12:00:00Z",
@ -1026,9 +1027,10 @@ class TestSessionLoaderUTF8Encoding:
messages_file = session_folder / "messages.jsonl" messages_file = session_folder / "messages.jsonl"
messages_file.write_text('{"role": "user", "content": "Hello"}\n') 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) metadata = SessionLoader.load_metadata(session_folder)
assert metadata.session_id == "latin1-test" assert metadata.session_id == "latin1-test"
assert metadata.environment["working_directory"] == "/home/user/caf<61>_project" assert metadata == expected
def test_load_session_with_utf8_metadata_and_messages( def test_load_session_with_utf8_metadata_and_messages(
self, session_config: SessionLoggingConfig self, session_config: SessionLoggingConfig

View file

@ -78,9 +78,9 @@
</g> </g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)"> <g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#121212" x="0" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="1.5" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="573.4" y="1.5" width="402.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/> <rect fill="#121212" x="0" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="1.5" width="805.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="829.6" y="1.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix"> <g class="terminal-matrix">
<text class="terminal-r1" x="0" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"></text><text class="terminal-r3" x="24.4" y="20" textLength="549" clip-path="url(#terminal-line-0)">Teleported&#160;to&#160;Nuage:&#160;https://chat.example.com</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"> <text class="terminal-r1" x="0" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"></text><text class="terminal-r3" x="24.4" y="20" textLength="805.2" clip-path="url(#terminal-line-0)">Teleported&#160;to&#160;a&#160;new&#160;async&#160;coding&#160;session:&#160;https://chat.example.com</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"> </text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"> </text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"> </text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Before After
Before After

View file

@ -180,9 +180,9 @@
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"> </text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"> </text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"> </text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="483.6" textLength="183" clip-path="url(#terminal-line-19)">devstral-latest</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"> </text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="414.8" clip-path="url(#terminal-line-20)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"> </text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="61" clip-path="url(#terminal-line-20)">Type&#160;</text><text class="terminal-r3" x="231.8" y="508" textLength="61" clip-path="url(#terminal-line-20)">/help</text><text class="terminal-r1" x="292.8" y="508" textLength="256.2" clip-path="url(#terminal-line-20)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type&#160;</text><text class="terminal-r3" x="231.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">/help</text><text class="terminal-r1" x="292.8" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"> </text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"> </text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r5" x="24.4" y="581.2" textLength="122" clip-path="url(#terminal-line-23)">What&#x27;s&#160;New</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"> </text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r5" x="24.4" y="581.2" textLength="122" clip-path="url(#terminal-line-23)">What&#x27;s&#160;New</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="24.4" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">&#160;Feature&#160;1</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"> </text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="24.4" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">&#160;Feature&#160;1</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

138
tests/test_deferred_init.py Normal file
View file

@ -0,0 +1,138 @@
"""Tests for deferred initialization: _complete_init, wait_for_init, integrate_mcp idempotency."""
from __future__ import annotations
import threading
from unittest.mock import MagicMock, patch
import pytest
from tests.conftest import build_test_agent_loop, build_test_vibe_config
from tests.stubs.fake_mcp_registry import FakeMCPRegistry
from vibe.core.config import MCPStdio
from vibe.core.tools.manager import ToolManager
# ---------------------------------------------------------------------------
# _complete_init
# ---------------------------------------------------------------------------
class TestCompleteInit:
def test_success_sets_init_complete(self) -> None:
loop = build_test_agent_loop(defer_heavy_init=True)
assert not loop.is_initialized
loop._complete_init()
assert loop.is_initialized
assert loop._init_error is None
def test_failure_sets_init_complete_and_stores_error(self) -> None:
loop = build_test_agent_loop(defer_heavy_init=True)
error = RuntimeError("mcp boom")
with patch.object(loop.tool_manager, "integrate_mcp", side_effect=error):
loop._complete_init()
assert loop.is_initialized
assert loop._init_error is error
def test_mcp_integration_internal_failure_sets_init_error(self) -> None:
mcp_server = MCPStdio(name="test-server", transport="stdio", command="echo")
config = build_test_vibe_config(mcp_servers=[mcp_server])
loop = build_test_agent_loop(config=config, defer_heavy_init=True)
with patch.object(
loop.tool_manager._mcp_registry,
"get_tools",
side_effect=RuntimeError("mcp discovery boom"),
):
loop._complete_init()
assert loop.is_initialized
assert isinstance(loop._init_error, RuntimeError)
assert str(loop._init_error) == "mcp discovery boom"
# ---------------------------------------------------------------------------
# wait_for_init
# ---------------------------------------------------------------------------
class TestWaitForInit:
@pytest.mark.asyncio
async def test_returns_immediately_when_already_complete(self) -> None:
loop = build_test_agent_loop(defer_heavy_init=True)
loop._complete_init()
await loop.wait_for_init() # should not block
@pytest.mark.asyncio
async def test_waits_for_background_thread(self) -> None:
loop = build_test_agent_loop(defer_heavy_init=True)
thread = threading.Thread(target=loop._complete_init, daemon=True)
thread.start()
await loop.wait_for_init()
thread.join(timeout=1)
assert loop.is_initialized
@pytest.mark.asyncio
async def test_raises_stored_error(self) -> None:
loop = build_test_agent_loop(defer_heavy_init=True)
error = RuntimeError("init failed")
with patch.object(loop.tool_manager, "integrate_mcp", side_effect=error):
loop._complete_init()
with pytest.raises(RuntimeError, match="init failed"):
await loop.wait_for_init()
@pytest.mark.asyncio
async def test_raises_error_for_every_caller(self) -> None:
loop = build_test_agent_loop(defer_heavy_init=True)
error = RuntimeError("once only")
with patch.object(loop.tool_manager, "integrate_mcp", side_effect=error):
loop._complete_init()
with pytest.raises(RuntimeError):
await loop.wait_for_init()
with pytest.raises(RuntimeError):
await loop.wait_for_init()
# ---------------------------------------------------------------------------
# integrate_mcp idempotency
# ---------------------------------------------------------------------------
class TestIntegrateMcpIdempotency:
def test_second_call_is_noop(self) -> None:
mcp_server = MCPStdio(name="test-server", transport="stdio", command="echo")
config = build_test_vibe_config(mcp_servers=[mcp_server])
registry = FakeMCPRegistry()
manager = ToolManager(lambda: config, mcp_registry=registry, defer_mcp=True)
manager.integrate_mcp()
tools_after_first = dict(manager.registered_tools)
# Spy on the registry to ensure get_tools is not called again.
registry.get_tools = MagicMock(wraps=registry.get_tools)
manager.integrate_mcp()
registry.get_tools.assert_not_called()
assert manager.registered_tools == tools_after_first
def test_flag_not_set_when_no_servers(self) -> None:
config = build_test_vibe_config(mcp_servers=[])
manager = ToolManager(lambda: config, defer_mcp=True)
manager.integrate_mcp()
# No servers means the method returns early without setting the flag,
# so a future call with servers would still run discovery.
assert not manager._mcp_integrated

View file

@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest import pytest
from vibe.core.config import TTSModelConfig, TTSProviderConfig from vibe.core.config import TTSModelConfig, TTSProviderConfig
@ -27,13 +27,20 @@ def _make_model() -> TTSModelConfig:
class TestMistralTTSClientInit: 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 self, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "test-key") monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
client = MistralTTSClient(_make_provider(), _make_model()) client = MistralTTSClient(_make_provider(), _make_model())
assert str(client._client.base_url) == "https://api.mistral.ai/v1/" sdk_client = client._get_client()
assert client._client.headers["authorization"] == "Bearer test-key" assert sdk_client is not None
assert client._client is sdk_client
assert client._get_client() is sdk_client
class TestMistralTTSClient: class TestMistralTTSClient:
@ -46,55 +53,62 @@ class TestMistralTTSClient:
raw_audio = b"fake-audio-data-for-testing" raw_audio = b"fake-audio-data-for-testing"
encoded_audio = base64.b64encode(raw_audio).decode() encoded_audio = base64.b64encode(raw_audio).decode()
async def mock_post(self_client, url, **kwargs): mock_response = MagicMock()
assert url == "/audio/speech" mock_response.audio_data = encoded_audio
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),
)
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post) mock_complete_async = AsyncMock(return_value=mock_response)
client = MistralTTSClient(_make_provider(), _make_model()) client = MistralTTSClient(_make_provider(), _make_model())
with patch.object(
type(client._get_client().audio.speech),
"complete_async",
mock_complete_async,
):
result = await client.speak("Hello") result = await client.speak("Hello")
assert isinstance(result, TTSResult) assert isinstance(result, TTSResult)
assert result.audio_data == raw_audio assert result.audio_data == raw_audio
await client.close() mock_complete_async.assert_called_once_with(
model="voxtral-mini-tts-latest",
@pytest.mark.asyncio input="Hello",
async def test_speak_raises_on_http_error( voice_id="gb_jane_neutral",
self, monkeypatch: pytest.MonkeyPatch response_format="wav",
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
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)
client = MistralTTSClient(_make_provider(), _make_model())
with pytest.raises(httpx.HTTPStatusError):
await client.speak("Hello")
await client.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_close_closes_underlying_client( async def test_speak_raises_on_sdk_error(
self, monkeypatch: pytest.MonkeyPatch self, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "test-key") monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
import httpx
from mistralai.client.errors import SDKError
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()) client = MistralTTSClient(_make_provider(), _make_model())
with (
patch.object(
type(client._get_client().audio.speech),
"complete_async",
mock_complete_async,
),
pytest.raises(SDKError),
):
await client.speak("Hello")
@pytest.mark.asyncio
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() await client.close()
assert client._client.is_closed assert client._client is None

View file

@ -122,7 +122,7 @@ def test_load_whats_new_content_handles_os_error(tmp_path: Path) -> None:
whats_new_file.write_text("content") whats_new_file.write_text("content")
with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path): 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() result = load_whats_new_content()
assert result is None assert result is None

37
uv.lock generated
View file

@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.12" requires-python = ">=3.12"
[options] [options]
exclude-newer = "2026-04-02T14:58:56.644057Z" exclude-newer = "2026-04-06T21:12:11.186502Z"
exclude-newer-span = "P7D" exclude-newer-span = "P7D"
[options.exclude-newer-package] [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" }, { 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]] [[package]]
name = "jsonpath-python" name = "jsonpath-python"
version = "1.1.5" 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" }, { 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]] [[package]]
name = "jsonschema" name = "jsonschema"
version = "4.26.0" version = "4.26.0"
@ -787,17 +808,19 @@ wheels = [
[[package]] [[package]]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.7.4" version = "2.7.5"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "agent-client-protocol" }, { name = "agent-client-protocol" },
{ name = "anyio" }, { name = "anyio" },
{ name = "cachetools" }, { name = "cachetools" },
{ name = "charset-normalizer" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "gitpython" }, { name = "gitpython" },
{ name = "giturlparse" }, { name = "giturlparse" },
{ name = "google-auth" }, { name = "google-auth" },
{ name = "httpx" }, { name = "httpx" },
{ name = "jsonpatch" },
{ name = "keyring" }, { name = "keyring" },
{ name = "markdownify" }, { name = "markdownify" },
{ name = "mcp" }, { name = "mcp" },
@ -853,11 +876,13 @@ requires-dist = [
{ name = "agent-client-protocol", specifier = "==0.9.0" }, { name = "agent-client-protocol", specifier = "==0.9.0" },
{ name = "anyio", specifier = ">=4.12.0" }, { name = "anyio", specifier = ">=4.12.0" },
{ name = "cachetools", specifier = ">=5.5.0" }, { name = "cachetools", specifier = ">=5.5.0" },
{ name = "charset-normalizer", specifier = ">=3.4.4" },
{ name = "cryptography", specifier = ">=44.0.0,<=46.0.3" }, { name = "cryptography", specifier = ">=44.0.0,<=46.0.3" },
{ name = "gitpython", specifier = ">=3.1.46" }, { name = "gitpython", specifier = ">=3.1.46" },
{ name = "giturlparse", specifier = ">=0.14.0" }, { name = "giturlparse", specifier = ">=0.14.0" },
{ name = "google-auth", specifier = ">=2.0.0" }, { name = "google-auth", specifier = ">=2.0.0" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "jsonpatch", specifier = ">=1.33" },
{ name = "keyring", specifier = ">=25.6.0" }, { name = "keyring", specifier = ">=25.6.0" },
{ name = "markdownify", specifier = ">=1.2.2" }, { name = "markdownify", specifier = ">=1.2.2" },
{ name = "mcp", specifier = ">=1.14.0" }, { name = "mcp", specifier = ">=1.14.0" },
@ -876,7 +901,7 @@ requires-dist = [
{ name = "requests", specifier = ">=2.20.0" }, { name = "requests", specifier = ">=2.20.0" },
{ name = "rich", specifier = ">=14.0.0" }, { name = "rich", specifier = ">=14.0.0" },
{ name = "sounddevice", specifier = ">=0.5.1" }, { 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 = "textual-speedups", specifier = ">=0.2.1" },
{ name = "tomli-w", specifier = ">=1.2.0" }, { name = "tomli-w", specifier = ">=1.2.0" },
{ name = "tree-sitter", specifier = ">=0.25.2" }, { name = "tree-sitter", specifier = ">=0.25.2" },
@ -1901,7 +1926,7 @@ wheels = [
[[package]] [[package]]
name = "textual" name = "textual"
version = "8.1.1" version = "8.2.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] }, { name = "markdown-it-py", extra = ["linkify"] },
@ -1911,9 +1936,9 @@ dependencies = [
{ name = "rich" }, { name = "rich" },
{ name = "typing-extensions" }, { 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 = [ 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]] [[package]]

View file

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
VIBE_ROOT = Path(__file__).parent VIBE_ROOT = Path(__file__).parent
__version__ = "2.7.4" __version__ = "2.7.5"

View file

@ -19,10 +19,12 @@ from vibe.core.tools.builtins.search_replace import (
SearchReplaceResult, SearchReplaceResult,
) )
from vibe.core.types import ToolCallEvent, ToolResultEvent from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.utils.io import ReadSafeResult
class AcpSearchReplaceState(BaseToolState, AcpToolState): class AcpSearchReplaceState(BaseToolState, AcpToolState):
file_backup_content: str | None = None file_backup_content: str | None = None
file_backup_encoding: str = "utf-8"
class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]): class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
@ -35,7 +37,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
def _get_tool_state_class(cls) -> type[AcpSearchReplaceState]: def _get_tool_state_class(cls) -> type[AcpSearchReplaceState]:
return 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() client, session_id, _ = self._load_state()
await self._send_in_progress_session_update() 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 raise ToolError(f"Unexpected error reading {file_path}: {e}") from e
self.state.file_backup_content = response.content 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: async def _backup_file(self, file_path: Path) -> None:
if self.state.file_backup_content is None: if self.state.file_backup_content is None:
@ -57,9 +60,10 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
await self._write_file( await self._write_file(
file_path.with_suffix(file_path.suffix + ".bak"), file_path.with_suffix(file_path.suffix + ".bak"),
self.state.file_backup_content, 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() client, session_id, _ = self._load_state()
try: try:

View file

@ -207,11 +207,14 @@ def run_cli(args: argparse.Namespace) -> None:
client_name="vibe_cli", client_name="vibe_cli",
client_version=__version__, client_version=__version__,
), ),
defer_heavy_init=True,
) )
if loaded_session: if loaded_session:
_resume_previous_session(agent_loop, *loaded_session) _resume_previous_session(agent_loop, *loaded_session)
agent_loop.start_deferred_init()
run_textual_ui( run_textual_ui(
agent_loop=agent_loop, agent_loop=agent_loop,
startup=StartupOptions( startup=StartupOptions(

View file

@ -10,7 +10,7 @@ from rich import print as rprint
from vibe import __version__ from vibe import __version__
from vibe.core.agents.models import BuiltinAgentName from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config.harness_files import init_harness_files_manager 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 ( from vibe.setup.trusted_folders.trust_folder_dialog import (
TrustDialogQuitException, TrustDialogQuitException,
ask_trust_folder, ask_trust_folder,
@ -118,7 +118,12 @@ def check_and_resolve_trusted_folder() -> None:
) )
sys.exit(1) 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 return
is_folder_trusted = trusted_folders_manager.is_trusted(cwd) is_folder_trusted = trusted_folders_manager.is_trusted(cwd)
@ -127,7 +132,7 @@ def check_and_resolve_trusted_folder() -> None:
return return
try: try:
is_folder_trusted = ask_trust_folder(cwd) is_folder_trusted = ask_trust_folder(cwd, detected_files)
except (KeyboardInterrupt, EOFError, TrustDialogQuitException): except (KeyboardInterrupt, EOFError, TrustDialogQuitException):
sys.exit(0) sys.exit(0)
except Exception as e: except Exception as e:

View file

@ -3,6 +3,8 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
from vibe.core.utils.io import read_safe
class HistoryManager: class HistoryManager:
def __init__(self, history_file: Path, max_entries: int = 100) -> None: def __init__(self, history_file: Path, max_entries: int = 100) -> None:
@ -18,9 +20,13 @@ class HistoryManager:
return return
try: try:
with self.history_file.open("r", encoding="utf-8") as f: text = read_safe(self.history_file).text
except OSError:
self._entries = []
return
entries = [] entries = []
for raw_line in f: for raw_line in text.splitlines():
raw_line = raw_line.rstrip("\n\r") raw_line = raw_line.rstrip("\n\r")
if not raw_line: if not raw_line:
continue continue
@ -30,8 +36,6 @@ class HistoryManager:
entry = raw_line entry = raw_line
entries.append(entry if isinstance(entry, str) else str(entry)) entries.append(entry if isinstance(entry, str) else str(entry))
self._entries = entries[-self.max_entries :] self._entries = entries[-self.max_entries :]
except (OSError, UnicodeDecodeError):
self._entries = []
def _save_history(self) -> None: def _save_history(self) -> None:
try: try:

View file

@ -7,6 +7,7 @@ from vibe.cli.narrator_manager.narrator_manager_port import (
NarratorManagerListener, NarratorManagerListener,
NarratorState, NarratorState,
) )
from vibe.cli.narrator_manager.telemetry import ReadAloudTrackingState
from vibe.cli.turn_summary import ( from vibe.cli.turn_summary import (
NoopTurnSummary, NoopTurnSummary,
TurnSummaryResult, TurnSummaryResult,
@ -24,16 +25,21 @@ if TYPE_CHECKING:
from vibe.cli.turn_summary import TurnSummaryPort from vibe.cli.turn_summary import TurnSummaryPort
from vibe.core.audio_player.audio_player_port import AudioPlayerPort from vibe.core.audio_player.audio_player_port import AudioPlayerPort
from vibe.core.config import VibeConfig 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.tts.tts_client_port import TTSClientPort
from vibe.core.types import BaseEvent from vibe.core.types import BaseEvent
class NarratorManager: class NarratorManager:
def __init__( def __init__(
self, config_getter: Callable[[], VibeConfig], audio_player: AudioPlayerPort self,
config_getter: Callable[[], VibeConfig],
audio_player: AudioPlayerPort,
telemetry_client: TelemetryClient | None = None,
) -> None: ) -> None:
self._config_getter = config_getter self._config_getter = config_getter
self._audio_player = audio_player self._audio_player = audio_player
self._telemetry_client = telemetry_client
config = config_getter() config = config_getter()
self._turn_summary: TurnSummaryPort = self._make_turn_summary(config) self._turn_summary: TurnSummaryPort = self._make_turn_summary(config)
self._turn_summary.on_summary = self._on_turn_summary self._turn_summary.on_summary = self._on_turn_summary
@ -43,6 +49,7 @@ class NarratorManager:
self._cancel_summary: Callable[[], bool] | None = None self._cancel_summary: Callable[[], bool] | None = None
self._close_tasks: set[asyncio.Task[Any]] = set() self._close_tasks: set[asyncio.Task[Any]] = set()
self._listeners: list[NarratorManagerListener] = [] self._listeners: list[NarratorManagerListener] = []
self._tracking = ReadAloudTrackingState()
@property @property
def state(self) -> NarratorState: def state(self) -> NarratorState:
@ -99,8 +106,12 @@ class NarratorManager:
): ):
self._cancel_summary = cancel_summary self._cancel_summary = cancel_summary
self._set_state(NarratorState.SUMMARIZING) self._set_state(NarratorState.SUMMARIZING)
self._tracking.reset()
self._on_read_aloud_requested()
def cancel(self) -> None: def cancel(self) -> None:
if self._state != NarratorState.IDLE:
self._on_read_aloud_ended("canceled")
if self._cancel_summary is not None: if self._cancel_summary is not None:
self._cancel_summary() self._cancel_summary()
self._cancel_summary = None self._cancel_summary = None
@ -178,17 +189,65 @@ class NarratorManager:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
tts_result = await self._tts_client.speak(text) tts_result = await self._tts_client.speak(text)
self._set_state(NarratorState.SPEAKING) self._set_state(NarratorState.SPEAKING)
self._tracking.mark_play_started()
self._on_read_aloud_play_started()
self._audio_player.play( self._audio_player.play(
tts_result.audio_data, tts_result.audio_data,
AudioFormat.WAV, AudioFormat.WAV,
on_finished=lambda: loop.call_soon_threadsafe( 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) logger.warning("TTS speak failed", exc_info=True)
self._on_read_aloud_ended("error", error_type=type(exc).__name__)
self._set_state(NarratorState.IDLE) 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: def _set_state(self, state: NarratorState) -> None:
self._state = state self._state = state
for listener in list(self._listeners): for listener in list(self._listeners):

View file

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

View file

@ -71,10 +71,18 @@ def stop_and_print() -> None:
output_path = Path(f"{_state.label}-profile.html") output_path = Path(f"{_state.label}-profile.html")
output_path.write_text(_state.profiler.output_html(), encoding="utf-8") 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( print(
f"\n[profiler:{_state.label}] Saved HTML profile to {output_path.resolve()}", f"\n[profiler:{_state.label}] Saved HTML profile to {output_path.resolve()}",
file=sys.stderr, 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) print(_state.profiler.output_text(color=True), file=sys.stderr)
_state.profiler = None _state.profiler = None

View file

@ -191,7 +191,7 @@ def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]: def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]:
if keybindings_path.exists(): if keybindings_path.exists():
content = read_safe(keybindings_path) content = read_safe(keybindings_path).text
return _parse_keybindings(content) return _parse_keybindings(content)
keybindings_path.parent.mkdir(parents=True, exist_ok=True) keybindings_path.parent.mkdir(parents=True, exist_ok=True)
return [] return []

View file

@ -370,6 +370,7 @@ class VibeApp(App): # noqa: PLR0904
self._rewind_mode = False self._rewind_mode = False
self._rewind_highlighted_widget: UserMessage | None = None self._rewind_highlighted_widget: UserMessage | None = None
self._fatal_init_error = False
@property @property
def config(self) -> VibeConfig: def config(self) -> VibeConfig:
@ -449,6 +450,8 @@ class VibeApp(App): # noqa: PLR0904
self.call_after_refresh(self._refresh_banner) self.call_after_refresh(self._refresh_banner)
self.run_worker(self._watch_init_completion(), exclusive=False)
if self._show_resume_picker: if self._show_resume_picker:
self.run_worker(self._show_session_picker(), exclusive=False) self.run_worker(self._show_session_picker(), exclusive=False)
elif self._initial_prompt or self._teleport_on_start: elif self._initial_prompt or self._teleport_on_start:
@ -457,6 +460,37 @@ class VibeApp(App): # noqa: PLR0904
gc.collect() gc.collect()
gc.freeze() 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: def _process_initial_prompt(self) -> None:
if self._teleport_on_start: if self._teleport_on_start:
self.run_worker( self.run_worker(
@ -470,6 +504,10 @@ class VibeApp(App): # noqa: PLR0904
def _is_file_watcher_enabled(self) -> bool: def _is_file_watcher_enabled(self) -> bool:
return self.config.file_watcher_for_autocomplete 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( async def on_chat_input_container_submitted(
self, event: ChatInputContainer.Submitted self, event: ChatInputContainer.Submitted
) -> None: ) -> None:
@ -754,7 +792,7 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill") self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
try: try:
skill_content = read_safe(skill_info.skill_path) skill_content = read_safe(skill_info.skill_path).text
except OSError as e: except OSError as e:
await self._mount_and_scroll( await self._mount_and_scroll(
ErrorMessage( ErrorMessage(
@ -1059,9 +1097,17 @@ class VibeApp(App): # noqa: PLR0904
self._agent_running = True self._agent_running = True
await self._remove_loading_widget() await self._remove_loading_widget()
await self._ensure_loading_widget()
try: 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()) rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
self._narrator_manager.cancel() self._narrator_manager.cancel()
self._narrator_manager.on_turn_start(rendered_prompt) self._narrator_manager.on_turn_start(rendered_prompt)
@ -1088,6 +1134,11 @@ class VibeApp(App): # noqa: PLR0904
except Exception as e: except Exception as e:
await self._handle_turn_error() 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) message = str(e)
if isinstance(e, RateLimitError): if isinstance(e, RateLimitError):
message = self._rate_limit_message() message = self._rate_limit_message()
@ -2542,7 +2593,9 @@ class VibeApp(App): # noqa: PLR0904
def _make_default_narrator_manager(self) -> NarratorManager: def _make_default_narrator_manager(self) -> NarratorManager:
return 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,
) )

View file

@ -26,7 +26,7 @@ class ExternalEditor:
parts = shlex.split(editor) parts = shlex.split(editor)
subprocess.run([*parts, filepath], check=True) 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 return content if content != initial_content else None
except (OSError, subprocess.CalledProcessError): except (OSError, subprocess.CalledProcessError):
return return

View file

@ -68,6 +68,8 @@ class Banner(Static):
self.state = self._initial_state self.state = self._initial_state
def watch_state(self) -> None: 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-model", NoMarkupStatic).update(self.state.active_model)
self.query_one("#banner-meta-counts", NoMarkupStatic).update( self.query_one("#banner-meta-counts", NoMarkupStatic).update(
self._format_meta_counts() self._format_meta_counts()

View file

@ -78,6 +78,11 @@ class MCPApp(Container):
self._refresh_view(self._viewing_server) self._refresh_view(self._viewing_server)
self.query_one(OptionList).focus() 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: def on_descendant_blur(self, _event: DescendantBlur) -> None:
self.query_one(OptionList).focus() self.query_one(OptionList).focus()

View file

@ -15,7 +15,7 @@ class TeleportMessage(StatusMessage):
if self._error: if self._error:
return f"Teleport failed: {self._error}" return f"Teleport failed: {self._error}"
if self._final_url: 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 return self._status
def set_status(self, status: str) -> None: def set_status(self, status: str) -> None:

View file

@ -24,7 +24,7 @@ def load_whats_new_content() -> str | None:
if not whats_new_file.exists(): if not whats_new_file.exists():
return None return None
try: try:
content = read_safe(whats_new_file).strip() content = read_safe(whats_new_file).text.strip()
return content if content else None return content if content else None
except OSError: except OSError:
return None return None

View file

@ -3,9 +3,11 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import AsyncGenerator, Callable, Generator from collections.abc import AsyncGenerator, Callable, Generator
import contextlib import contextlib
import copy
from enum import StrEnum, auto from enum import StrEnum, auto
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path from pathlib import Path
import threading
from threading import Thread from threading import Thread
import time import time
from typing import TYPE_CHECKING, Any, Literal from typing import TYPE_CHECKING, Any, Literal
@ -152,6 +154,7 @@ class AgentLoop:
def __init__( def __init__(
self, self,
config: VibeConfig, config: VibeConfig,
*,
agent_name: str = BuiltinAgentName.DEFAULT, agent_name: str = BuiltinAgentName.DEFAULT,
message_observer: Callable[[LLMMessage], None] | None = None, message_observer: Callable[[LLMMessage], None] | None = None,
max_turns: int | None = None, max_turns: int | None = None,
@ -160,8 +163,12 @@ class AgentLoop:
enable_streaming: bool = False, enable_streaming: bool = False,
entrypoint_metadata: EntrypointMetadata | None = None, entrypoint_metadata: EntrypointMetadata | None = None,
is_subagent: bool = False, is_subagent: bool = False,
defer_heavy_init: bool = False,
) -> None: ) -> None:
self._base_config = config self._base_config = config
self._init_complete = threading.Event()
self._init_error: Exception | None = None
self.mcp_registry = MCPRegistry() self.mcp_registry = MCPRegistry()
self.agent_manager = AgentManager( self.agent_manager = AgentManager(
lambda: self._base_config, lambda: self._base_config,
@ -169,7 +176,9 @@ class AgentLoop:
allow_subagent=is_subagent, allow_subagent=is_subagent,
) )
self.tool_manager = ToolManager( 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.skill_manager = SkillManager(lambda: self.config)
self.message_observer = message_observer self.message_observer = message_observer
@ -190,11 +199,18 @@ class AgentLoop:
self._setup_middleware() self._setup_middleware()
system_prompt = get_universal_system_prompt( 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) system_message = LLMMessage(role=Role.system, content=system_prompt)
self.messages = MessageList(initial=[system_message], observer=message_observer) self.messages = MessageList(initial=[system_message], observer=message_observer)
if not defer_heavy_init:
self._init_complete.set()
self.stats = AgentStats() self.stats = AgentStats()
self.approval_callback: ApprovalCallback | None = None self.approval_callback: ApprovalCallback | None = None
self.user_input_callback: UserInputCallback | None = None self.user_input_callback: UserInputCallback | None = None
@ -233,6 +249,55 @@ class AgentLoop:
) )
thread.start() 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 @property
def agent_profile(self) -> AgentProfile: def agent_profile(self) -> AgentProfile:
return self.agent_manager.active_profile return self.agent_manager.active_profile
@ -272,7 +337,7 @@ class AgentLoop:
self.config.tools[tool_name]["permission"] = permission.value 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) self._session_rules.append(rule)
def _is_permission_covered(self, tool_name: str, rp: RequiredPermission) -> bool: 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.""" """Handle 'Allow Always' approval: add session rules or set tool-level permission."""
if required_permissions: if required_permissions:
for rp in required_permissions: for rp in required_permissions:
self.add_session_rule( self._add_session_rule(
ApprovedRule( ApprovedRule(
tool_name=tool_name, tool_name=tool_name,
scope=rp.scope, scope=rp.scope,

View file

@ -113,7 +113,7 @@ class IgnoreRules:
gitignore_path = root / ".gitignore" gitignore_path = root / ".gitignore"
if gitignore_path.exists(): if gitignore_path.exists():
try: try:
text = read_safe(gitignore_path) text = read_safe(gitignore_path).text
except Exception: except Exception:
return patterns return patterns

View file

@ -9,6 +9,7 @@ from vibe.core.autocompletion.path_prompt import (
PathResource, PathResource,
build_path_prompt_payload, build_path_prompt_payload,
) )
from vibe.core.utils.io import decode_safe
DEFAULT_MAX_EMBED_BYTES = 256 * 1024 DEFAULT_MAX_EMBED_BYTES = 256 * 1024
@ -67,11 +68,7 @@ def _try_embed_text_resource(
if not _is_probably_text(resource, data): if not _is_probably_text(resource, data):
return None return None
try: text = decode_safe(data).text
text = data.decode("utf-8")
except UnicodeDecodeError:
return None
return {"type": "resource", "uri": resource.path.as_uri(), "text": text} return {"type": "resource", "uri": resource.path.as_uri(), "text": text}

View file

@ -11,6 +11,7 @@ from typing import Annotated, Any, Literal
from urllib.parse import urljoin from urllib.parse import urljoin
from dotenv import dotenv_values from dotenv import dotenv_values
from mistralai.client.models import SpeechOutputFormat
from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
DEFAULT_TRACES_EXPORT_PATH, DEFAULT_TRACES_EXPORT_PATH,
) )
@ -22,6 +23,7 @@ from pydantic_settings import (
PydanticBaseSettingsSource, PydanticBaseSettingsSource,
SettingsConfigDict, SettingsConfigDict,
) )
from pydantic_settings.sources.base import deep_update
import tomli_w import tomli_w
from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.config.harness_files import get_harness_files_manager
@ -96,6 +98,29 @@ class TomlFileSettingsSource(PydanticBaseSettingsSource):
return self.toml_data 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): class ProjectContextConfig(BaseSettings):
model_config = SettingsConfigDict(extra="ignore") model_config = SettingsConfigDict(extra="ignore")
@ -121,16 +146,55 @@ class SessionLoggingConfig(BaseSettings):
return str(Path(v).expanduser().resolve()) 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): class ProviderConfig(BaseModel):
name: str name: str
api_base: str api_base: str
api_key_env_var: 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" api_style: str = "openai"
backend: Backend = Backend.GENERIC backend: Backend = Backend.GENERIC
reasoning_field_name: str = "reasoning_content" reasoning_field_name: str = "reasoning_content"
project_id: str = "" project_id: str = ""
region: 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): class TranscribeClient(StrEnum):
MISTRAL = auto() MISTRAL = auto()
@ -289,7 +353,7 @@ class TTSModelConfig(BaseModel):
provider: str provider: str
alias: str alias: str
voice: str = "gb_jane_neutral" 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) _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 headers: dict[str, str] | None = None
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
MISTRAL_OTEL_PATH = "/telemetry" MISTRAL_OTEL_PATH = "/telemetry"
_DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai" _DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai"
@ -310,6 +373,8 @@ DEFAULT_PROVIDERS = [
name="mistral", name="mistral",
api_base=f"{_DEFAULT_MISTRAL_SERVER_URL}/v1", api_base=f"{_DEFAULT_MISTRAL_SERVER_URL}/v1",
api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY, 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, backend=Backend.MISTRAL,
), ),
ProviderConfig( ProviderConfig(
@ -343,6 +408,8 @@ DEFAULT_MODELS = [
), ),
] ]
DEFAULT_ACTIVE_MODEL = DEFAULT_MODELS[0].alias
DEFAULT_TRANSCRIBE_PROVIDERS = [ DEFAULT_TRANSCRIBE_PROVIDERS = [
TranscribeProviderConfig( TranscribeProviderConfig(
name="mistral", name="mistral",
@ -375,7 +442,7 @@ DEFAULT_TTS_MODELS = [
class VibeConfig(BaseSettings): class VibeConfig(BaseSettings):
active_model: str = "devstral-2" active_model: str = DEFAULT_ACTIVE_MODEL
vim_keybindings: bool = False vim_keybindings: bool = False
disable_welcome_banner_animation: bool = False disable_welcome_banner_animation: bool = False
autocopy_to_clipboard: bool = True autocopy_to_clipboard: bool = True
@ -529,7 +596,8 @@ class VibeConfig(BaseSettings):
def otel_span_exporter_config(self) -> OtelSpanExporterConfig | None: def otel_span_exporter_config(self) -> OtelSpanExporterConfig | None:
# When otel_endpoint is set explicitly, authentication is the user's responsibility # When otel_endpoint is set explicitly, authentication is the user's responsibility
# (via OTEL_EXPORTER_OTLP_* env vars), so headers are left empty. # (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("/") traces_export_path = DEFAULT_TRACES_EXPORT_PATH.lstrip("/")
if self.otel_endpoint: if self.otel_endpoint:
return OtelSpanExporterConfig( return OtelSpanExporterConfig(
@ -538,9 +606,7 @@ class VibeConfig(BaseSettings):
) )
) )
provider = next( provider = self.get_mistral_provider()
(p for p in self.providers if p.backend == Backend.MISTRAL), None
)
if provider is not None: if provider is not None:
server_url = get_server_url_from_api_base(provider.api_base) server_url = get_server_url_from_api_base(provider.api_base)
@ -578,7 +644,7 @@ class VibeConfig(BaseSettings):
".md" ".md"
) )
if custom_sp_path.is_file(): if custom_sp_path.is_file():
return read_safe(custom_sp_path) return read_safe(custom_sp_path).text
raise MissingPromptFileError( raise MissingPromptFileError(
self.system_prompt_id, *(str(d) for d in prompt_dirs) self.system_prompt_id, *(str(d) for d in prompt_dirs)
@ -597,6 +663,15 @@ class VibeConfig(BaseSettings):
return self.compaction_model return self.compaction_model
return self.get_active_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: def get_provider_for_model(self, model: ModelConfig) -> ProviderConfig:
for provider in self.providers: for provider in self.providers:
if provider.name == model.provider: if provider.name == model.provider:
@ -776,37 +851,8 @@ class VibeConfig(BaseSettings):
if not get_harness_files_manager().persist_allowed: if not get_harness_files_manager().persist_allowed:
return return
current_config = TomlFileSettingsSource(cls).toml_data current_config = TomlFileSettingsSource(cls).toml_data
merged_config = deep_update(current_config, updates)
def deep_merge(target: dict, source: dict) -> None: cls.dump_config(merged_config)
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)
@classmethod @classmethod
def dump_config(cls, config: dict[str, Any]) -> None: def dump_config(cls, config: dict[str, Any]) -> None:
@ -815,8 +861,10 @@ class VibeConfig(BaseSettings):
return return
target = mgr.config_file or mgr.user_config_file target = mgr.config_file or mgr.user_config_file
target.parent.mkdir(parents=True, exist_ok=True) 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: 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 @classmethod
def _migrate(cls) -> None: def _migrate(cls) -> None:

View file

@ -10,7 +10,12 @@ from vibe.core.config.harness_files._paths import (
GLOBAL_SKILLS_DIR, GLOBAL_SKILLS_DIR,
GLOBAL_TOOLS_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.trusted_folders import trusted_folders_manager
from vibe.core.utils.io import read_safe from vibe.core.utils.io import read_safe
@ -72,25 +77,23 @@ class HarnessFilesManager:
d = GLOBAL_AGENTS_DIR.path d = GLOBAL_AGENTS_DIR.path
return [d] if d.is_dir() else [] return [d] if d.is_dir() else []
def _walk_project_dirs( def _walk_project_dirs(self) -> ConfigWalkResult:
self,
) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]:
workdir = self.trusted_workdir workdir = self.trusted_workdir
if workdir is None: if workdir is None:
return ((), (), ()) return ConfigWalkResult()
return walk_local_config_dirs_all(workdir) return walk_local_config_dirs(workdir)
@property @property
def project_tools_dirs(self) -> list[Path]: def project_tools_dirs(self) -> list[Path]:
return list(self._walk_project_dirs()[0]) return list(self._walk_project_dirs().tools)
@property @property
def project_skills_dirs(self) -> list[Path]: def project_skills_dirs(self) -> list[Path]:
return list(self._walk_project_dirs()[1]) return list(self._walk_project_dirs().skills)
@property @property
def project_agents_dirs(self) -> list[Path]: def project_agents_dirs(self) -> list[Path]:
return list(self._walk_project_dirs()[2]) return list(self._walk_project_dirs().agents)
@property @property
def user_config_file(self) -> Path: def user_config_file(self) -> Path:
@ -116,7 +119,7 @@ class HarnessFilesManager:
return "" return ""
path = VIBE_HOME.path / AGENTS_MD_FILENAME path = VIBE_HOME.path / AGENTS_MD_FILENAME
try: try:
stripped = read_safe(path).strip() stripped = read_safe(path).text.strip()
return stripped if stripped else "" return stripped if stripped else ""
except (FileNotFoundError, OSError): except (FileNotFoundError, OSError):
return "" return ""
@ -140,7 +143,7 @@ class HarnessFilesManager:
break break
path = current / AGENTS_MD_FILENAME path = current / AGENTS_MD_FILENAME
try: try:
stripped = read_safe(path).strip() stripped = read_safe(path).text.strip()
if stripped: if stripped:
docs.append((current, stripped)) docs.append((current, stripped))
except (FileNotFoundError, OSError): except (FileNotFoundError, OSError):

View file

@ -454,7 +454,7 @@ class AnthropicAdapter(APIAdapter):
return payload return payload
def prepare_request( # noqa: PLR0913 def prepare_request(
self, self,
*, *,
model_name: str, model_name: str,

View file

@ -19,7 +19,7 @@ class PreparedRequest(NamedTuple):
class APIAdapter(Protocol): class APIAdapter(Protocol):
endpoint: ClassVar[str] endpoint: ClassVar[str]
def prepare_request( # noqa: PLR0913 def prepare_request(
self, self,
*, *,
model_name: str, model_name: str,

View file

@ -78,7 +78,7 @@ class OpenAIAdapter(APIAdapter):
msg_dict["reasoning_content"] = msg_dict.pop(field_name) msg_dict["reasoning_content"] = msg_dict.pop(field_name)
return msg_dict return msg_dict
def prepare_request( # noqa: PLR0913 def prepare_request(
self, self,
*, *,
model_name: str, model_name: str,

View file

@ -107,7 +107,7 @@ class ReasoningAdapter(APIAdapter):
return payload return payload
def prepare_request( # noqa: PLR0913 def prepare_request(
self, self,
*, *,
model_name: str, model_name: str,

View file

@ -64,7 +64,7 @@ class VertexAnthropicAdapter(AnthropicAdapter):
super().__init__() super().__init__()
self.credentials = VertexCredentials() self.credentials = VertexCredentials()
def prepare_request( # noqa: PLR0913 def prepare_request(
self, self,
*, *,
model_name: str, model_name: str,

View file

@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator, Callable
import json import json
from typing import Any, cast from typing import Any, cast
from jsonpatch import JsonPatch, JsonPatchException # type: ignore[import-untyped]
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from vibe.core.nuage.agent_models import AgentCompletionState from vibe.core.nuage.agent_models import AgentCompletionState
@ -16,7 +17,6 @@ from vibe.core.nuage.events import (
CustomTaskTimedOut, CustomTaskTimedOut,
JSONPatchAppend, JSONPatchAppend,
JSONPatchPayload, JSONPatchPayload,
JSONPatchRemove,
JSONPatchReplace, JSONPatchReplace,
JSONPayload, JSONPayload,
WorkflowEvent, WorkflowEvent,
@ -61,11 +61,57 @@ from vibe.core.types import (
) )
_WAIT_FOR_INPUT_TASK_TYPE = "wait_for_input" _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 # These names must match the remote workflow's tool naming convention
_ASK_USER_QUESTION_TOOL = "ask_user_question" _ASK_USER_QUESTION_TOOL = "ask_user_question"
_SEND_USER_MESSAGE_TOOL = "send_user_message" _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( class _RemoteTool(
BaseTool[RemoteToolArgs, RemoteToolResult, BaseToolConfig, BaseToolState], BaseTool[RemoteToolArgs, RemoteToolResult, BaseToolConfig, BaseToolState],
ToolUIData[RemoteToolArgs, RemoteToolResult], ToolUIData[RemoteToolArgs, RemoteToolResult],
@ -146,6 +192,7 @@ class RemoteWorkflowEventTranslator:
self._pending_input_request: PendingInputRequest | None = None self._pending_input_request: PendingInputRequest | None = None
self._pending_question_prompt: str | None = None self._pending_question_prompt: str | None = None
self._pending_ask_user_question_call_id: 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 self._last_status: WorkflowExecutionStatus | None = None
@property @property
@ -315,12 +362,18 @@ class RemoteWorkflowEventTranslator:
payload_value = event.attributes.payload.value payload_value = event.attributes.payload.value
label: str | None = None 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): if isinstance(payload_value, dict):
self._pending_input_request = PendingInputRequest.model_validate({ self._pending_input_request = PendingInputRequest.model_validate({
"task_id": event.attributes.custom_task_id, "task_id": event.attributes.custom_task_id,
**payload_value, **payload_value,
}) })
label = self._pending_input_request.label
events: list[BaseEvent] = [] events: list[BaseEvent] = []
if label: if label:
@ -345,6 +398,10 @@ class RemoteWorkflowEventTranslator:
if event.attributes.custom_task_type != _WAIT_FOR_INPUT_TASK_TYPE: if event.attributes.custom_task_type != _WAIT_FOR_INPUT_TASK_TYPE:
return None 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_input_request = None
self._pending_question_prompt = None self._pending_question_prompt = None
ask_user_question_call_id = self._pending_ask_user_question_call_id 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 self, previous_state: dict[str, Any], payload: JSONPatchPayload
) -> dict[str, Any]: ) -> dict[str, Any]:
new_state = cast(dict[str, Any], self._json_safe_value(previous_state)) new_state = cast(dict[str, Any], self._json_safe_value(previous_state))
for patch in payload.value: 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): if isinstance(patch, JSONPatchAppend):
current = self._get_path(new_state, path) current = _get_value_at_path(patch.path, new_state)
self._set_path(new_state, path, f"{current or ''}{patch.value}") _set_value_at_path(
continue patch.path, new_state, f"{current or ''}{patch.value}"
)
self._set_path(new_state, path, 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 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( def _completion_events(
self, task_id: str, previous_state: dict[str, Any], state: dict[str, Any] self, task_id: str, previous_state: dict[str, Any], state: dict[str, Any]
) -> list[BaseEvent]: ) -> list[BaseEvent]:

View file

@ -2,8 +2,8 @@ from __future__ import annotations
from vibe.core.paths._local_config_walk import ( from vibe.core.paths._local_config_walk import (
WALK_MAX_DEPTH, WALK_MAX_DEPTH,
has_config_dirs_nearby, ConfigWalkResult,
walk_local_config_dirs_all, walk_local_config_dirs,
) )
from vibe.core.paths._vibe_home import ( from vibe.core.paths._vibe_home import (
DEFAULT_TOOL_DIR, DEFAULT_TOOL_DIR,
@ -31,7 +31,7 @@ __all__ = [
"TRUSTED_FOLDERS_FILE", "TRUSTED_FOLDERS_FILE",
"VIBE_HOME", "VIBE_HOME",
"WALK_MAX_DEPTH", "WALK_MAX_DEPTH",
"ConfigWalkResult",
"GlobalPath", "GlobalPath",
"has_config_dirs_nearby", "walk_local_config_dirs",
"walk_local_config_dirs_all",
] ]

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections import deque from collections import deque
from dataclasses import dataclass, field
from functools import cache from functools import cache
import logging import logging
import os import os
@ -21,24 +22,59 @@ WALK_MAX_DEPTH = 4
_MAX_DIRS = 2000 _MAX_DIRS = 2000
def _collect_config_dirs_at( @dataclass(frozen=True)
path: Path, class ConfigWalkResult:
entry_names: set[str], """Aggregated results of a config directory walk."""
tools: list[Path],
skills: list[Path], config_dirs: tuple[Path, ...] = ()
agents: list[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: ) -> None:
"""Check a single directory for .vibe/ and .agents/ config subdirs.""" """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(): 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(): 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(): if (candidate := path / _AGENTS_SUBDIR).is_dir():
agents.append(candidate) collector.agents.append(candidate)
if _AGENTS_DIR in entry_names: 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(): 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]]: 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 @cache
def walk_local_config_dirs_all( def walk_local_config_dirs(
root: Path, root: Path, *, max_depth: int = WALK_MAX_DEPTH, max_dirs: int = _MAX_DIRS
) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]: ) -> ConfigWalkResult:
"""Discover .vibe/ and .agents/ config directories under *root*. """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. 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() resolved_root = root.resolve()
queue: deque[tuple[Path, int]] = deque([(resolved_root, 0)]) queue: deque[tuple[Path, int]] = deque([(resolved_root, 0)])
visited = 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: while queue and visited < max_dirs:
current, depth = queue.popleft() current, depth = queue.popleft()
visited += 1 visited += 1
@ -130,11 +128,16 @@ def has_config_dirs_nearby(
if not entry_names: if not entry_names:
continue continue
_collect_config_dirs_at(current, entry_names, found, found, found) _collect_at(current, entry_names, collector)
if found:
return True
if depth < max_depth: if depth < max_depth:
queue.extend((child, depth + 1) for child in children) 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()

View file

@ -15,7 +15,7 @@ class Prompt(StrEnum):
return (_PROMPTS_DIR / self.value).with_suffix(".md") return (_PROMPTS_DIR / self.value).with_suffix(".md")
def read(self) -> str: def read(self) -> str:
return read_safe(self.path).strip() return read_safe(self.path).text.strip()
class SystemPrompt(Prompt): class SystemPrompt(Prompt):

View file

@ -34,21 +34,19 @@ class SessionLoader:
return False return False
try: try:
with metadata_path.open("r", encoding="utf-8", errors="ignore") as f: metadata = json.loads(read_safe(metadata_path).text)
metadata = json.load(f)
if not isinstance(metadata, dict): if not isinstance(metadata, dict):
return False return False
with messages_path.open("r", encoding="utf-8", errors="ignore") as f:
has_messages = False has_messages = False
for line in f: for line in read_safe(messages_path).text.splitlines():
has_messages = True has_messages = True
message = json.loads(line) message = json.loads(line)
if not isinstance(message, dict): if not isinstance(message, dict):
return False return False
if not has_messages: if not has_messages:
return False return False
except (OSError, UnicodeDecodeError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
return False return False
return True return True
@ -144,8 +142,7 @@ class SessionLoader:
metadata_path = session_dir / METADATA_FILENAME metadata_path = session_dir / METADATA_FILENAME
try: try:
with metadata_path.open("r", encoding="utf-8") as f: metadata = json.loads(read_safe(metadata_path).text)
metadata = json.load(f)
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
continue continue
@ -182,7 +179,7 @@ class SessionLoader:
raise ValueError(f"Session metadata not found at {session_dir}") raise ValueError(f"Session metadata not found at {session_dir}")
try: try:
metadata_content = read_safe(metadata_path) metadata_content = read_safe(metadata_path).text
return SessionMetadata.model_validate_json(metadata_content) return SessionMetadata.model_validate_json(metadata_content)
except ValueError: except ValueError:
raise raise
@ -197,7 +194,7 @@ class SessionLoader:
messages_filepath = filepath / MESSAGES_FILENAME messages_filepath = filepath / MESSAGES_FILENAME
try: try:
content = read_safe(messages_filepath).split("\n") content = read_safe(messages_filepath).text.split("\n")
if content and content[-1] == "": if content and content[-1] == "":
content.pop() content.pop()
except Exception as e: except Exception as e:
@ -228,10 +225,7 @@ class SessionLoader:
if metadata_filepath.exists(): if metadata_filepath.exists():
try: try:
with metadata_filepath.open( metadata = json.loads(read_safe(metadata_filepath).text)
"r", encoding="utf-8", errors="ignore"
) as f:
metadata = json.load(f)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
raise ValueError( raise ValueError(
f"Session metadata contains invalid JSON (may have been corrupted): " f"Session metadata contains invalid JSON (may have been corrupted): "

View file

@ -231,7 +231,7 @@ class SessionLogger:
# Read old metadata and get total_messages # Read old metadata and get total_messages
try: try:
if self.metadata_filepath.exists(): 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_metadata = json.loads(raw)
old_total_messages = old_metadata["total_messages"] old_total_messages = old_metadata["total_messages"]
else: else:

View file

@ -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")) session_files = list(Path(save_dir).glob(f"{session_config.session_prefix}_*.json"))
for session_file in session_files: for session_file in session_files:
try: try:
session_data = read_safe(session_file) session_data = read_safe(session_file).text
session_json = json.loads(session_data) session_json = json.loads(session_data)
metadata = session_json["metadata"] metadata = session_json["metadata"]
messages = session_json["messages"] messages = session_json["messages"]

View file

@ -107,7 +107,7 @@ class SkillManager:
def _parse_skill_file(self, skill_path: Path) -> SkillInfo: def _parse_skill_file(self, skill_path: Path) -> SkillInfo:
try: try:
content = read_safe(skill_path) content = read_safe(skill_path).text
except OSError as e: except OSError as e:
raise SkillParseError(f"Cannot read file: {e}") from e raise SkillParseError(f"Cannot read file: {e}") from e

View file

@ -136,8 +136,8 @@ class ProjectContextProvider:
except Exception as e: except Exception as e:
return f"Error getting git status: {e}" return f"Error getting git status: {e}"
def get_full_context(self) -> str: def get_full_context(self, *, include_git_status: bool = True) -> str:
git_status = self.get_git_status() git_status = self.get_git_status() if include_git_status else ""
template = UtilityPrompt.PROJECT_CONTEXT.read() template = UtilityPrompt.PROJECT_CONTEXT.read()
return Template(template).safe_substitute( return Template(template).safe_substitute(
@ -249,6 +249,8 @@ def get_universal_system_prompt(
config: VibeConfig, config: VibeConfig,
skill_manager: SkillManager, skill_manager: SkillManager,
agent_manager: AgentManager, agent_manager: AgentManager,
*,
include_git_status: bool = True,
) -> str: ) -> str:
sections = [config.system_prompt] sections = [config.system_prompt]
@ -285,7 +287,7 @@ def get_universal_system_prompt(
else: else:
context = ProjectContextProvider( context = ProjectContextProvider(
config=config.project_context, root_path=Path.cwd() config=config.project_context, root_path=Path.cwd()
).get_full_context() ).get_full_context(include_git_status=include_git_status)
sections.append(context) sections.append(context)

View file

@ -4,19 +4,20 @@ import asyncio
from collections.abc import Callable from collections.abc import Callable
import os import os
from typing import TYPE_CHECKING, Any, Literal from typing import TYPE_CHECKING, Any, Literal
from urllib.parse import urljoin
import httpx import httpx
from vibe import __version__ 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.llm.format import ResolvedToolCall
from vibe.core.types import Backend from vibe.core.utils import get_server_url_from_api_base, get_user_agent
from vibe.core.utils import get_user_agent
if TYPE_CHECKING: if TYPE_CHECKING:
from vibe.core.agent_loop import ToolDecision 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: class TelemetryClient:
@ -31,31 +32,35 @@ class TelemetryClient:
self._pending_tasks: set[asyncio.Task[Any]] = set() self._pending_tasks: set[asyncio.Task[Any]] = set()
self.last_correlation_id: str | None = None self.last_correlation_id: str | None = None
def _get_telemetry_user_agent(self) -> str: def _get_telemetry_url(self, api_base: str) -> str:
try: base = get_server_url_from_api_base(api_base) or _DEFAULT_TELEMETRY_BASE_URL
config = self._config_getter() return urljoin(base.rstrip("/"), _DATALAKE_EVENTS_PATH)
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_mistral_api_key(self) -> str | None: 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 Only returns an API key if the provider is a Mistral provider
to avoid leaking third-party credentials to the telemetry endpoint. to avoid leaking third-party credentials to the telemetry endpoint.
""" """
try: provider_and_api_key = self._get_mistral_provider_and_api_key()
config = self._config_getter() if provider_and_api_key is None:
model = config.get_active_model()
provider = config.get_provider_for_model(model)
if provider.backend != Backend.MISTRAL:
return None return None
env_var = provider.api_key_env_var _, api_key = provider_and_api_key
return os.getenv(env_var) if env_var else None return api_key
def _get_mistral_provider_and_api_key(self) -> tuple[ProviderConfig, str] | None:
try:
provider = self._config_getter().get_mistral_provider()
except ValueError: except ValueError:
return None 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: def _is_enabled(self) -> bool:
"""Check if telemetry is enabled in the current config.""" """Check if telemetry is enabled in the current config."""
@ -83,10 +88,14 @@ class TelemetryClient:
*, *,
correlation_id: str | None = None, correlation_id: str | None = None,
) -> None: ) -> None:
mistral_api_key = self._get_mistral_api_key() if not self._is_enabled():
if mistral_api_key is None or not self._is_enabled():
return 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 ( if (
self._session_id_getter is not None self._session_id_getter is not None
and (session_id := 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: async def _send() -> None:
try: try:
await self.client.post( await self.client.post(
DATALAKE_EVENTS_URL, telemetry_url,
json=payload, json=payload,
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",

View file

@ -134,7 +134,9 @@ class GitRepository:
try: try:
self._repo = Repo(self._workdir, search_parent_directories=True) self._repo = Repo(self._workdir, search_parent_directories=True)
except InvalidGitRepositoryError as e: 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 return self._repo
def _find_github_remote(self, repo: Repo) -> tuple[str, str] | None: def _find_github_remote(self, repo: Repo) -> tuple[str, str] | None:

View file

@ -167,7 +167,7 @@ class BaseTool[
prompt_dir = class_path.parent / "prompts" prompt_dir = class_path.parent / "prompts"
prompt_path = cls.prompt_path or prompt_dir / f"{class_path.stem}.md" 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): except (FileNotFoundError, TypeError, OSError):
pass pass

View file

@ -75,7 +75,7 @@ class ExitPlanMode(
plan_content: str | None = None plan_content: str | None = None
if ctx.plan_file_path and ctx.plan_file_path.is_file(): if ctx.plan_file_path and ctx.plan_file_path.is_file():
try: try:
plan_content = read_safe(ctx.plan_file_path) plan_content = read_safe(ctx.plan_file_path).text
except OSError as e: except OSError as e:
raise ToolError( raise ToolError(
f"Failed to read plan file at {ctx.plan_file_path}: {e}" f"Failed to read plan file at {ctx.plan_file_path}: {e}"

View file

@ -167,7 +167,7 @@ class Grep(
def _load_codeignore_patterns(self, codeignore_path: Path) -> list[str]: def _load_codeignore_patterns(self, codeignore_path: Path) -> list[str]:
patterns = [] patterns = []
try: try:
content = read_safe(codeignore_path) content = read_safe(codeignore_path).text
for line in content.splitlines(): for line in content.splitlines():
line = line.strip() line = line.strip()
if line and not line.startswith("#"): if line and not line.startswith("#"):

View file

@ -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.tools.utils import resolve_file_tool_permission
from vibe.core.types import ToolStreamEvent from vibe.core.types import ToolStreamEvent
from vibe.core.utils import VIBE_WARNING_TAG from vibe.core.utils import VIBE_WARNING_TAG
from vibe.core.utils.io import decode_safe
if TYPE_CHECKING: if TYPE_CHECKING:
from vibe.core.types import ToolResultEvent from vibe.core.types import ToolResultEvent
@ -73,8 +74,8 @@ class ReadFile(
ToolUIData[ReadFileArgs, ReadFileResult], ToolUIData[ReadFileArgs, ReadFileResult],
): ):
description: ClassVar[str] = ( description: ClassVar[str] = (
"Read a UTF-8 file, returning content from a specific line range. " "Read a text file (encoding detected safely), returning content from a "
"Reading is capped by a byte limit for safety." "specific line range. Reading is capped by a byte limit for safety."
) )
@final @final
@ -135,53 +136,38 @@ class ReadFile(
async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult: async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult:
try: try:
return await self._do_read_file(args, file_path, encoding="utf-8") raw_lines: list[bytes] = []
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] = []
bytes_read = 0 bytes_read = 0
was_truncated = False was_truncated = False
async with await anyio.Path(file_path).open( async with await anyio.Path(file_path).open("rb") as f:
encoding=encoding, errors=errors
) as f:
line_index = 0 line_index = 0
async for line in f: while raw_line := await f.readline():
if line_index < args.offset: if line_index < args.offset:
line_index += 1 line_index += 1
continue 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 break
line_bytes = len(line.encode("utf-8")) line_bytes = len(raw_line)
if bytes_read + line_bytes > self.config.max_read_bytes: if bytes_read + line_bytes > self.config.max_read_bytes:
was_truncated = True was_truncated = True
break break
lines_to_return.append(line) raw_lines.append(raw_line)
bytes_read += line_bytes bytes_read += line_bytes
line_index += 1 line_index += 1
return _ReadResult(
lines=lines_to_return,
bytes_read=bytes_read,
was_truncated=was_truncated,
)
except OSError as exc: except OSError as exc:
raise ToolError(f"Error reading {file_path}: {exc}") from 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: def _validate_inputs(self, args: ReadFileArgs) -> None:
if not args.path.strip(): if not args.path.strip():
raise ToolError("Path cannot be empty") raise ToolError("Path cannot be empty")

View file

@ -22,7 +22,7 @@ from vibe.core.tools.permissions import PermissionContext
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.tools.utils import resolve_file_tool_permission from vibe.core.tools.utils import resolve_file_tool_permission
from vibe.core.types import ToolResultEvent, ToolStreamEvent 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( SEARCH_REPLACE_BLOCK_RE = re.compile(
r"<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE", flags=re.DOTALL 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]: ) -> AsyncGenerator[ToolStreamEvent | SearchReplaceResult, None]:
file_path, search_replace_blocks = self._prepare_and_validate_args(args) 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( block_result = self._apply_blocks(
original_content, original_content,
@ -166,7 +167,7 @@ class SearchReplace(
except Exception: except Exception:
pass pass
await self._write_file(file_path, modified_content) await self._write_file(file_path, modified_content, decoded.encoding)
yield SearchReplaceResult( yield SearchReplaceResult(
file=str(file_path), file=str(file_path),
@ -221,7 +222,7 @@ class SearchReplace(
return file_path, search_replace_blocks 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: try:
return await read_safe_async(file_path, raise_on_error=True) return await read_safe_async(file_path, raise_on_error=True)
except PermissionError: except PermissionError:
@ -234,12 +235,16 @@ class SearchReplace(
async def _backup_file(self, file_path: Path) -> None: async def _backup_file(self, file_path: Path) -> None:
shutil.copy2(file_path, file_path.with_suffix(file_path.suffix + ".bak")) 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: try:
async with await anyio.Path(file_path).open( async with await anyio.Path(file_path).open(
mode="w", encoding="utf-8" mode="w", encoding=encoding
) as f: ) as f:
await f.write(content) 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: except PermissionError:
raise ToolError(f"Permission denied writing to file: {file_path}") raise ToolError(f"Permission denied writing to file: {file_path}")
except OSError as e: except OSError as e:

View file

@ -99,7 +99,7 @@ class Skill(
) )
try: try:
raw = read_safe(skill_info.skill_path) raw = read_safe(skill_info.skill_path).text
_, body = parse_frontmatter(raw) _, body = parse_frontmatter(raw)
except (OSError, SkillParseError) as e: except (OSError, SkillParseError) as e:
raise ToolError(f"Cannot load skill file: {e}") from e raise ToolError(f"Cannot load skill file: {e}") from e

View file

@ -7,6 +7,7 @@ import inspect
from pathlib import Path from pathlib import Path
import re import re
import sys import sys
import threading
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.config.harness_files import get_harness_files_manager
@ -69,16 +70,21 @@ class ToolManager:
self, self,
config_getter: Callable[[], VibeConfig], config_getter: Callable[[], VibeConfig],
mcp_registry: MCPRegistry | None = None, mcp_registry: MCPRegistry | None = None,
*,
defer_mcp: bool = False,
) -> None: ) -> None:
self._config_getter = config_getter self._config_getter = config_getter
self._mcp_registry = mcp_registry or MCPRegistry() self._mcp_registry = mcp_registry or MCPRegistry()
self._instances: dict[str, BaseTool] = {} self._instances: dict[str, BaseTool] = {}
self._search_paths: list[Path] = self._compute_search_paths(self._config) 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]] = { self._available: dict[str, type[BaseTool]] = {
cls.get_name(): cls for cls in self._iter_tool_classes(self._search_paths) 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 @property
def _config(self) -> VibeConfig: def _config(self) -> VibeConfig:
@ -176,10 +182,12 @@ class ToolManager:
@property @property
def registered_tools(self) -> dict[str, type[BaseTool]]: def registered_tools(self) -> dict[str, type[BaseTool]]:
return self._available with self._lock:
return dict(self._available)
@property @property
def available_tools(self) -> dict[str, type[BaseTool]]: def available_tools(self) -> dict[str, type[BaseTool]]:
with self._lock:
runtime_available = { runtime_available = {
name: cls for name, cls in self._available.items() if cls.is_available() name: cls for name, cls in self._available.items() if cls.is_available()
} }
@ -198,7 +206,14 @@ class ToolManager:
} }
return runtime_available 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: if not self._config.mcp_servers:
return return
@ -206,14 +221,19 @@ class ToolManager:
mcp_tools = self._mcp_registry.get_tools(self._config.mcp_servers) mcp_tools = self._mcp_registry.get_tools(self._config.mcp_servers)
except Exception as exc: except Exception as exc:
logger.warning("MCP integration failed: %s", exc) logger.warning("MCP integration failed: %s", exc)
if raise_on_failure:
raise
return return
self._available.update(mcp_tools) with self._lock:
self._available = {**self._available, **mcp_tools}
self._mcp_integrated = True
logger.info( logger.info(
"MCP integration registered %d tools (via registry)", len(mcp_tools) "MCP integration registered %d tools (via registry)", len(mcp_tools)
) )
def get_tool_config(self, tool_name: str) -> BaseToolConfig: def get_tool_config(self, tool_name: str) -> BaseToolConfig:
with self._lock:
tool_class = self._available.get(tool_name) tool_class = self._available.get(tool_name)
if tool_class: if tool_class:
@ -239,11 +259,11 @@ class ToolManager:
if tool_name in self._instances: if tool_name in self._instances:
return self._instances[tool_name] return self._instances[tool_name]
with self._lock:
if tool_name not in self._available: if tool_name not in self._available:
raise NoSuchToolError( raise NoSuchToolError(
f"Unknown tool: {tool_name}. Available: {list(self._available.keys())}" f"Unknown tool: {tool_name}. Available: {list(self._available.keys())}"
) )
tool_class = self._available[tool_name] tool_class = self._available[tool_name]
self._instances[tool_name] = tool_class.from_config( self._instances[tool_name] = tool_class.from_config(
lambda: self.get_tool_config(tool_name) lambda: self.get_tool_config(tool_name)

View file

@ -8,7 +8,7 @@ import tomli_w
from vibe.core.paths import ( from vibe.core.paths import (
AGENTS_MD_FILENAME, AGENTS_MD_FILENAME,
TRUSTED_FOLDERS_FILE, 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() return (path / AGENTS_MD_FILENAME).exists()
def has_trustable_content(path: Path) -> bool: def find_trustable_files(path: Path) -> list[str]:
if (path / ".vibe").exists(): """Return relative paths of files/dirs that would modify the agent's behavior."""
return True resolved = path.resolve()
found: list[str] = []
if has_agents_md_file(path): if has_agents_md_file(path):
return True found.append(AGENTS_MD_FILENAME)
return has_config_dirs_nearby(path)
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: class TrustedFoldersManager:

View file

@ -3,7 +3,8 @@ from __future__ import annotations
import base64 import base64
import os 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.config import TTSModelConfig, TTSProviderConfig
from vibe.core.tts.tts_client_port import TTSResult from vibe.core.tts.tts_client_port import TTSResult
@ -11,34 +12,30 @@ from vibe.core.tts.tts_client_port import TTSResult
class MistralTTSClient: class MistralTTSClient:
def __init__(self, provider: TTSProviderConfig, model: TTSModelConfig) -> None: 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._model_name = model.name
self._voice = model.voice self._voice = model.voice
self._response_format = model.response_format self._response_format: SpeechOutputFormat = model.response_format
self._client = httpx.AsyncClient( self._client: Mistral | None = None
base_url=f"{provider.api_base}/v1",
headers={ def _get_client(self) -> Mistral:
"Authorization": f"Bearer {os.getenv(provider.api_key_env_var, '')}", if self._client is None:
"Content-Type": "application/json", self._client = Mistral(api_key=self._api_key, server_url=self._server_url)
}, return self._client
timeout=60.0,
)
async def speak(self, text: str) -> TTSResult: async def speak(self, text: str) -> TTSResult:
response = await self._client.post( client = self._get_client()
"/audio/speech", response = await client.audio.speech.complete_async(
json={ model=self._model_name,
"model": self._model_name, input=text,
"input": text, voice_id=self._voice,
"voice_id": self._voice, response_format=self._response_format,
"stream": False,
"response_format": self._response_format,
},
) )
response.raise_for_status() audio_bytes = base64.b64decode(response.audio_data)
data = response.json()
audio_bytes = base64.b64decode(data["audio_data"])
return TTSResult(audio_data=audio_bytes) return TTSResult(audio_data=audio_bytes)
async def close(self) -> None: 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

View file

@ -487,7 +487,13 @@ class MessageList(Sequence[LLMMessage]):
hook() hook()
def update_system_prompt(self, new: str) -> None: 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) self._data[0] = LLMMessage(role=Role.system, content=new)
@contextmanager @contextmanager

View file

@ -1,6 +1,6 @@
"""Utilities package. Re-exports all public and test-used symbols from submodules. """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. vibe.core.utils.slug when needed to avoid circular imports with config.
""" """

View file

@ -1,29 +1,79 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterator
import locale
from pathlib import Path from pathlib import Path
from typing import NamedTuple
import anyio import anyio
from charset_normalizer import from_bytes
def read_safe(path: Path, *, raise_on_error: bool = False) -> str: class ReadSafeResult(NamedTuple):
"""Read a text file trying UTF-8 first, falling back to OS-default encoding. """Text decoded from a file and the codec name that successfully decoded it."""
On fallback, undecodable bytes are replaced with U+FFFD (REPLACEMENT CHARACTER). text: str
When raise_on_error is True, decode errors propagate. 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.
""" """
for encoding in _get_candidate_encodings(raw):
try: try:
return path.read_text(encoding="utf-8") return ReadSafeResult(raw.decode(encoding), encoding)
except (UnicodeDecodeError, ValueError): except (LookupError, UnicodeDecodeError, ValueError):
if raise_on_error: pass
return path.read_text() errors = "strict" if raise_on_error else "replace"
return path.read_text(errors="replace") return ReadSafeResult(raw.decode("utf-8", errors=errors), "utf-8")
async def read_safe_async(path: Path, *, raise_on_error: bool = False) -> str: def read_safe(path: Path, *, raise_on_error: bool = False) -> ReadSafeResult:
apath = anyio.Path(path) """Read ``path`` and decode with :func:`decode_safe`."""
try: return decode_safe(path.read_bytes(), raise_on_error=raise_on_error)
return await apath.read_text(encoding="utf-8")
except (UnicodeDecodeError, ValueError):
if raise_on_error: async def read_safe_async(
return await apath.read_text() path: Path, *, raise_on_error: bool = False
return await apath.read_text(errors="replace") ) -> ReadSafeResult:
"""Async :func:`read_safe` (``anyio``)."""
raw = await anyio.Path(path).read_bytes()
return decode_safe(raw, raise_on_error=raise_on_error)

View file

@ -26,6 +26,15 @@ def run_onboarding(app: App | None = None) -> None:
case None: case None:
rprint("\n[yellow]Setup cancelled. See you next time![/]") rprint("\n[yellow]Setup cancelled. See you next time![/]")
sys.exit(0) 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:"): case str() as s if s.startswith("save_error:"):
err = s.removeprefix("save_error:") err = s.removeprefix("save_error:")
rprint( rprint(

View file

@ -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())

View file

@ -13,11 +13,12 @@ from textual.widgets import Input, Link, Static
from vibe.cli.clipboard import copy_selection_to_clipboard from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic 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.paths import GLOBAL_ENV_FILE
from vibe.core.telemetry.send import TelemetryClient from vibe.core.telemetry.send import TelemetryClient
from vibe.core.types import Backend from vibe.core.types import Backend
from vibe.setup.onboarding.base import OnboardingScreen from vibe.setup.onboarding.base import OnboardingScreen
from vibe.setup.onboarding.context import OnboardingContext
PROVIDER_HELP = { PROVIDER_HELP = {
"mistral": ("https://console.mistral.ai/codestral/cli", "Mistral AI Studio") "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) 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:<empty>"
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): class ApiKeyScreen(OnboardingScreen):
BINDINGS: ClassVar[list[BindingType]] = [ BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+c", "cancel", "Cancel", show=False), Binding("ctrl+c", "cancel", "Cancel", show=False),
@ -40,11 +77,9 @@ class ApiKeyScreen(OnboardingScreen):
NEXT_SCREEN = None NEXT_SCREEN = None
def __init__(self) -> None: def __init__(self, provider: ProviderConfig | None = None) -> None:
super().__init__() super().__init__()
config = VibeConfig.model_construct() self.provider = _resolve_onboarding_provider(provider)
active_model = config.get_active_model()
self.provider = config.get_provider_for_model(active_model)
def _compose_provider_link(self, provider_name: str) -> ComposeResult: def _compose_provider_link(self, provider_name: str) -> ComposeResult:
if self.provider.name not in PROVIDER_HELP: if self.provider.name not in PROVIDER_HELP:
@ -124,20 +159,7 @@ class ApiKeyScreen(OnboardingScreen):
self._save_and_finish(event.value) self._save_and_finish(event.value)
def _save_and_finish(self, api_key: str) -> None: def _save_and_finish(self, api_key: str) -> None:
env_key = self.provider.api_key_env_var self.app.exit(persist_api_key(self.provider, api_key))
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")
def on_mouse_up(self, event: MouseUp) -> None: def on_mouse_up(self, event: MouseUp) -> None:
copy_selection_to_clipboard(self.app) copy_selection_to_clipboard(self.app)

View file

@ -6,7 +6,7 @@ from typing import Any, ClassVar
from textual import events from textual import events
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.binding import Binding, BindingType 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.message import Message
from textual.widgets import Static from textual.widgets import Static
@ -38,30 +38,61 @@ class TrustFolderDialog(CenterMiddle):
class Untrusted(Message): class Untrusted(Message):
pass 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) super().__init__(**kwargs)
self.folder_path = folder_path self.folder_path = folder_path
self.detected_files = detected_files
self.selected_option = 0 self.selected_option = 0
self.option_widgets: list[Static] = [] self.option_widgets: list[Static] = []
def compose(self) -> ComposeResult: def _compose_scroll_content(self) -> ComposeResult:
with CenterMiddle(id="trust-dialog"): why_content = (
yield NoMarkupStatic( "Files here can modify AI behavior. Malicious "
"⚠ Trust this folder and all its subfolders?", id="trust-dialog-title" "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-container"):
with CenterMiddle(id="trust-dialog"):
yield NoMarkupStatic("Trust this folder?", id="trust-dialog-title")
yield NoMarkupStatic( yield NoMarkupStatic(
str(self.folder_path), str(self.folder_path),
id="trust-dialog-path", id="trust-dialog-path",
classes="trust-dialog-path", classes="trust-dialog-path",
) )
with VerticalScroll(id="trust-dialog-content"):
yield from self._compose_scroll_content()
yield NoMarkupStatic( yield NoMarkupStatic(
"Files that can modify your Mistral Vibe setup were found here. Do you trust this folder and all its subfolders?", "Only trust folders you fully control",
id="trust-dialog-message", id="trust-dialog-footer-warning",
classes="trust-dialog-message", classes="trust-dialog-footer-warning",
) )
with Horizontal(id="trust-options-container"): with Horizontal(id="trust-options-container"):
options = ["Yes", "No"] options = ["Yes, trust this folder", "No, ignore config files"]
for idx, text in enumerate(options): for idx, text in enumerate(options):
widget = NoMarkupStatic( widget = NoMarkupStatic(
f" {idx + 1}. {text}", classes="trust-option" f" {idx + 1}. {text}", classes="trust-option"
@ -85,7 +116,7 @@ class TrustFolderDialog(CenterMiddle):
self.focus() self.focus()
def _update_options(self) -> None: def _update_options(self) -> None:
options = ["Yes", "No"] options = ["Yes, trust this folder", "No, ignore config files"]
if len(self.option_widgets) != len(options): if len(self.option_widgets) != len(options):
return return
@ -146,9 +177,12 @@ class TrustFolderApp(App):
Binding("ctrl+c", "quit_without_saving", "Quit", show=False, priority=True), 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) super().__init__(**kwargs)
self.folder_path = folder_path self.folder_path = folder_path
self.detected_files = detected_files
self._result: bool | None = None self._result: bool | None = None
self._quit_without_saving = False self._quit_without_saving = False
@ -156,7 +190,7 @@ class TrustFolderApp(App):
self.theme = "textual-ansi" self.theme = "textual-ansi"
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield TrustFolderDialog(self.folder_path) yield TrustFolderDialog(self.folder_path, self.detected_files)
def action_quit_without_saving(self) -> None: def action_quit_without_saving(self) -> None:
self._quit_without_saving = True self._quit_without_saving = True
@ -177,6 +211,6 @@ class TrustFolderApp(App):
return self._result return self._result
def ask_trust_folder(folder_path: Path) -> bool | None: def ask_trust_folder(folder_path: Path, detected_files: list[str]) -> bool | None:
app = TrustFolderApp(folder_path) app = TrustFolderApp(folder_path, detected_files)
return app.run_trust_dialog() return app.run_trust_dialog()

View file

@ -3,14 +3,25 @@ Screen {
background: transparent 80%; background: transparent 80%;
} }
#trust-dialog { #trust-dialog {
width: 70; max-width: 70;
max-width: 90%; overflow-y: scroll;
min-width: 50;
height: auto;
border: round ansi_bright_black; border: round ansi_bright_black;
background: transparent; background: transparent;
height:auto;
max-height: 1fr;
padding: 1; 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 { #trust-dialog-title {
@ -31,15 +42,42 @@ Screen {
margin-bottom: 1; margin-bottom: 1;
} }
#trust-dialog-message { .trust-dialog-section-header {
width: 100%; width: 100%;
height: auto; height: auto;
color: ansi_default; color: ansi_default;
text-align: center; text-align: center;
text-wrap: wrap; text-wrap: wrap;
padding: 0 2;
}
.trust-dialog-section-center {
width: 100%;
height: auto;
margin-bottom: 1; 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 { #trust-options-container {
width: 100%; width: 100%;
height: auto; height: auto;
@ -79,5 +117,4 @@ Screen {
text-align: center; text-align: center;
text-style: italic; text-style: italic;
text-wrap: wrap; text-wrap: wrap;
margin-bottom: 1;
} }

View file

@ -1,4 +1,4 @@
# What's new in v2.7.4 # What's new in v2.7.5
- **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 - **Trust dialog**: Display detected files and LLM risks in the trust folder dialog
- **Console view**: New `ctrl+\` keybind to toggle logs display. - **Faster startup**: Deferred MCP and git I/O to a background thread for faster CLI startup