mirror of
https://github.com/mistralai/mistral-vibe
synced 2026-04-25 17:14:55 +02:00
v2.7.5 (#589)
Co-authored-by: Bastien <bastien.baret@gmail.com> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Julien Legrand <72564015+JulienLGRD@users.noreply.github.com> Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -200,5 +200,6 @@ result-*
|
||||
tests/playground/*
|
||||
.
|
||||
|
||||
# Profiler HTML reports (generated by vibe.cli.profiler)
|
||||
# Profiler HTML/TXT reports (generated by vibe.cli.profiler)
|
||||
*-profile.html
|
||||
*-profile.txt
|
||||
|
||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "2.7.4",
|
||||
"version": "2.7.5",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ACP Server",
|
||||
|
||||
14
AGENTS.md
14
AGENTS.md
@@ -134,6 +134,20 @@ guidelines:
|
||||
- uv run script.py to run a script within the uv environment
|
||||
- uv run pytest (or any other python tool) to run the tool within the uv environment
|
||||
|
||||
- title: "Safe File Reading"
|
||||
description: >
|
||||
When reading files from disk, prefer the helpers in `vibe.core.utils.io` over raw
|
||||
`Path.read_text()`, `Path.read_bytes().decode()`, or `open()` calls:
|
||||
- `read_safe(path)` — synchronous read with automatic encoding detection.
|
||||
- `read_safe_async(path)` — async equivalent (anyio-based).
|
||||
- `decode_safe(raw)` — decode an already-read `bytes` object.
|
||||
These functions try UTF-8 first, then BOM detection, the locale encoding, and
|
||||
`charset_normalizer` (lazily, only when cheaper candidates fail). They return a
|
||||
`ReadSafeResult(text, encoding)` so callers always get valid `str` output without
|
||||
having to handle encoding errors manually.
|
||||
Use `raise_on_error=True` only when the caller must distinguish corrupt files from
|
||||
valid ones; the default (`False`) replaces undecodable bytes with U+FFFD.
|
||||
|
||||
- title: "Imports in Cursor (no Pylance)"
|
||||
description: >
|
||||
Cursor's built-in Pyright does not offer the "Add import" quick fix (Ctrl+.). To add a missing import:
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.7.5] - 2026-04-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Display detected files and LLM risks in trust folder dialog
|
||||
- Text-to-speech via the Mistral SDK with telemetry tracking
|
||||
- Deferred MCP and git I/O to background thread for faster CLI startup
|
||||
- Made telemetry URL configurable
|
||||
- Bumped Textual to 8.2.1
|
||||
|
||||
### Fixed
|
||||
|
||||
- Encoding detection fallback in `read_safe` for non-UTF-8 files
|
||||
- Config saving logic cleanup
|
||||
|
||||
|
||||
## [2.7.4] - 2026-04-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "mistral-vibe"
|
||||
name = "Mistral Vibe"
|
||||
description = "Mistral's open-source coding assistant"
|
||||
version = "2.7.4"
|
||||
version = "2.7.5"
|
||||
schema_version = 1
|
||||
authors = ["Mistral AI"]
|
||||
repository = "https://github.com/mistralai/mistral-vibe"
|
||||
@@ -11,25 +11,25 @@ name = "Mistral Vibe"
|
||||
icon = "./icons/mistral_vibe.svg"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-darwin-aarch64-2.7.4.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-darwin-aarch64-2.7.5.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-darwin-x86_64-2.7.4.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-darwin-x86_64-2.7.5.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-linux-aarch64-2.7.4.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-linux-aarch64-2.7.5.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-linux-x86_64-2.7.4.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-linux-x86_64-2.7.5.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.windows-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-windows-aarch64-2.7.4.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-windows-aarch64-2.7.5.zip"
|
||||
cmd = "./vibe-acp.exe"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.4/vibe-acp-windows-x86_64-2.7.4.zip"
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.5/vibe-acp-windows-x86_64-2.7.5.zip"
|
||||
cmd = "./vibe-acp.exe"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mistral-vibe"
|
||||
version = "2.7.4"
|
||||
version = "2.7.5"
|
||||
description = "Minimal CLI coding agent by Mistral"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -30,11 +30,13 @@ dependencies = [
|
||||
"agent-client-protocol==0.9.0",
|
||||
"anyio>=4.12.0",
|
||||
"cachetools>=5.5.0",
|
||||
"charset-normalizer>=3.4.4",
|
||||
"cryptography>=44.0.0,<=46.0.3",
|
||||
"gitpython>=3.1.46",
|
||||
"giturlparse>=0.14.0",
|
||||
"google-auth>=2.0.0",
|
||||
"httpx>=0.28.1",
|
||||
"jsonpatch>=1.33",
|
||||
"keyring>=25.6.0",
|
||||
"markdownify>=1.2.2",
|
||||
"mcp>=1.14.0",
|
||||
@@ -53,7 +55,7 @@ dependencies = [
|
||||
"requests>=2.20.0",
|
||||
"rich>=14.0.0",
|
||||
"sounddevice>=0.5.1",
|
||||
"textual>=8.1.1",
|
||||
"textual>=8.2.1",
|
||||
"textual-speedups>=0.2.1",
|
||||
"tomli-w>=1.2.0",
|
||||
"tree-sitter>=0.25.2",
|
||||
@@ -171,10 +173,12 @@ order-by-type = true
|
||||
required-imports = ["from __future__ import annotations"]
|
||||
|
||||
[tool.ruff.lint.pylint]
|
||||
max-public-methods = 25
|
||||
max-positional-args = 10
|
||||
max-statements = 50
|
||||
max-branches = 15
|
||||
max-locals = 15
|
||||
max-args = 9
|
||||
max-args = 10
|
||||
max-returns = 6
|
||||
max-nested-blocks = 5
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ class TestACPInitialize:
|
||||
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.4"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.5"
|
||||
)
|
||||
|
||||
assert response.auth_methods == []
|
||||
@@ -52,7 +52,7 @@ class TestACPInitialize:
|
||||
session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.4"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.5"
|
||||
)
|
||||
|
||||
assert response.auth_methods is not None
|
||||
|
||||
@@ -25,7 +25,7 @@ class TestEdit:
|
||||
def test_returns_modified_content(self) -> None:
|
||||
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
with patch("pathlib.Path.read_text", return_value="modified"):
|
||||
with patch("pathlib.Path.read_bytes", return_value=b"modified"):
|
||||
with patch("pathlib.Path.unlink"):
|
||||
editor = ExternalEditor()
|
||||
result = editor.edit("original")
|
||||
@@ -35,7 +35,7 @@ class TestEdit:
|
||||
def test_returns_none_when_content_unchanged(self) -> None:
|
||||
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
|
||||
with patch("subprocess.run"):
|
||||
with patch("pathlib.Path.read_text", return_value="same"):
|
||||
with patch("pathlib.Path.read_bytes", return_value=b"same"):
|
||||
with patch("pathlib.Path.unlink"):
|
||||
editor = ExternalEditor()
|
||||
result = editor.edit("same")
|
||||
@@ -44,7 +44,7 @@ class TestEdit:
|
||||
def test_strips_trailing_whitespace(self) -> None:
|
||||
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
|
||||
with patch("subprocess.run"):
|
||||
with patch("pathlib.Path.read_text", return_value="content\n\n"):
|
||||
with patch("pathlib.Path.read_bytes", return_value=b"content\n\n"):
|
||||
with patch("pathlib.Path.unlink"):
|
||||
editor = ExternalEditor()
|
||||
result = editor.edit("original")
|
||||
@@ -53,7 +53,7 @@ class TestEdit:
|
||||
def test_handles_editor_with_args(self) -> None:
|
||||
with patch.dict("os.environ", {"VISUAL": "code --wait"}, clear=True):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
with patch("pathlib.Path.read_text", return_value="edited"):
|
||||
with patch("pathlib.Path.read_bytes", return_value=b"edited"):
|
||||
with patch("pathlib.Path.unlink"):
|
||||
editor = ExternalEditor()
|
||||
editor.edit("original")
|
||||
|
||||
@@ -39,6 +39,8 @@ def get_base_config() -> dict[str, Any]:
|
||||
"name": "mistral",
|
||||
"api_base": "https://api.mistral.ai/v1",
|
||||
"api_key_env_var": "MISTRAL_API_KEY",
|
||||
"browser_auth_base_url": "https://console.mistral.ai",
|
||||
"browser_auth_api_base_url": "https://console.mistral.ai/api",
|
||||
"backend": "mistral",
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from typing import Literal, TypedDict, Unpack
|
||||
|
||||
import pytest
|
||||
import tomli_w
|
||||
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.core.config import ModelConfig, VibeConfig
|
||||
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.config._settings import (
|
||||
DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL,
|
||||
DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL,
|
||||
DEFAULT_PROVIDERS,
|
||||
)
|
||||
from vibe.core.config.harness_files import (
|
||||
HarnessFilesManager,
|
||||
init_harness_files_manager,
|
||||
@@ -15,6 +22,66 @@ from vibe.core.config.harness_files import (
|
||||
)
|
||||
from vibe.core.paths import VIBE_HOME
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
from vibe.core.types import Backend
|
||||
from vibe.setup.onboarding.context import OnboardingContext
|
||||
|
||||
|
||||
class _ProviderConfigOverrides(TypedDict, total=False):
|
||||
api_key_env_var: str
|
||||
browser_auth_base_url: str | None
|
||||
browser_auth_api_base_url: str | None
|
||||
api_style: str
|
||||
backend: Backend
|
||||
reasoning_field_name: str
|
||||
project_id: str
|
||||
region: str
|
||||
|
||||
|
||||
class _ModelConfigOverrides(TypedDict, total=False):
|
||||
temperature: float
|
||||
input_price: float
|
||||
output_price: float
|
||||
thinking: Literal["off", "low", "medium", "high"]
|
||||
auto_compact_threshold: int
|
||||
|
||||
|
||||
def _default_provider(name: str) -> ProviderConfig:
|
||||
return next(provider for provider in DEFAULT_PROVIDERS if provider.name == name)
|
||||
|
||||
|
||||
def _custom_provider(**overrides: Unpack[_ProviderConfigOverrides]) -> ProviderConfig:
|
||||
return ProviderConfig(
|
||||
name="custom-provider", api_base="https://custom.example/v1", **overrides
|
||||
)
|
||||
|
||||
|
||||
def _custom_model(**overrides: Unpack[_ModelConfigOverrides]) -> ModelConfig:
|
||||
return ModelConfig(
|
||||
name="custom-model",
|
||||
provider="custom-provider",
|
||||
alias="custom-model",
|
||||
**overrides,
|
||||
)
|
||||
|
||||
|
||||
def _custom_provider_payload(**overrides: object) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"name": "custom-provider",
|
||||
"api_base": "https://custom.example/v1",
|
||||
"api_key_env_var": "CUSTOM_API_KEY",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _custom_model_payload(**overrides: object) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"name": "custom-model",
|
||||
"provider": "custom-provider",
|
||||
"alias": "custom-model",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
class TestResolveConfigFile:
|
||||
@@ -86,6 +153,51 @@ class TestResolveConfigFile:
|
||||
assert mgr.config_file == VIBE_HOME.path / "config.toml"
|
||||
|
||||
|
||||
class TestSaveUpdates:
|
||||
def test_merges_nested_tool_updates_without_materializing_defaults(
|
||||
self, config_dir: Path
|
||||
) -> None:
|
||||
config_file = config_dir / "config.toml"
|
||||
data = {"tools": {"bash": {"default_timeout": 600}}}
|
||||
with config_file.open("wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
|
||||
VibeConfig.save_updates({"tools": {"bash": {"permission": "always"}}})
|
||||
|
||||
with config_file.open("rb") as f:
|
||||
result = tomllib.load(f)
|
||||
|
||||
assert result == {
|
||||
"tools": {"bash": {"default_timeout": 600, "permission": "always"}}
|
||||
}
|
||||
|
||||
def test_replaces_lists_instead_of_unioning_them(self, config_dir: Path) -> None:
|
||||
config_file = config_dir / "config.toml"
|
||||
data = {"installed_agents": ["lean", "other"]}
|
||||
with config_file.open("wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
|
||||
VibeConfig.save_updates({"installed_agents": ["lean"]})
|
||||
|
||||
with config_file.open("rb") as f:
|
||||
result = tomllib.load(f)
|
||||
|
||||
assert result["installed_agents"] == ["lean"]
|
||||
|
||||
def test_prunes_nested_none_values_before_writing(self, config_dir: Path) -> None:
|
||||
config_file = config_dir / "config.toml"
|
||||
data = {"tools": {"bash": {"default_timeout": 600, "permission": "always"}}}
|
||||
with config_file.open("wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
|
||||
VibeConfig.save_updates({"tools": {"bash": {"permission": None}}})
|
||||
|
||||
with config_file.open("rb") as f:
|
||||
result = tomllib.load(f)
|
||||
|
||||
assert result == {"tools": {"bash": {"default_timeout": 600}}}
|
||||
|
||||
|
||||
class TestMigrateRemovesFindFromBashAllowlist:
|
||||
def test_removes_find_from_config_file(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -177,6 +289,472 @@ class TestAutoCompactThresholdFallback:
|
||||
assert cfg2.get_active_model().auto_compact_threshold == 75_000
|
||||
|
||||
|
||||
class TestDefaultProviderConfig:
|
||||
def test_default_mistral_provider_is_mistral_backend(self) -> None:
|
||||
provider = _default_provider("mistral")
|
||||
|
||||
assert provider.name == "mistral"
|
||||
assert provider.backend.value == "mistral"
|
||||
assert provider.browser_auth_base_url == DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL
|
||||
assert (
|
||||
provider.browser_auth_api_base_url
|
||||
== DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL
|
||||
)
|
||||
assert provider.supports_browser_sign_in is True
|
||||
|
||||
def test_non_mistral_provider_does_not_inherit_browser_auth_defaults(self) -> None:
|
||||
provider = _default_provider("llamacpp")
|
||||
|
||||
assert provider.browser_auth_base_url is None
|
||||
assert provider.browser_auth_api_base_url is None
|
||||
assert provider.supports_browser_sign_in is False
|
||||
|
||||
|
||||
class TestMistralBrowserAuthConfig:
|
||||
def test_provider_browser_auth_urls_are_dumped_when_set(self) -> None:
|
||||
cfg = build_test_vibe_config()
|
||||
provider = cfg.get_provider_for_model(cfg.get_active_model())
|
||||
dumped = cfg.model_dump(mode="json")
|
||||
|
||||
assert provider.browser_auth_base_url == DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL
|
||||
assert (
|
||||
provider.browser_auth_api_base_url
|
||||
== DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL
|
||||
)
|
||||
assert (
|
||||
dumped["providers"][0]["browser_auth_base_url"]
|
||||
== DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL
|
||||
)
|
||||
assert (
|
||||
dumped["providers"][0]["browser_auth_api_base_url"]
|
||||
== DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL
|
||||
)
|
||||
|
||||
def test_legacy_explicit_mistral_provider_backfills_browser_auth_urls_without_changing_backend(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
|
||||
config_file = tmp_path / "config.toml"
|
||||
with config_file.open("wb") as f:
|
||||
tomli_w.dump(
|
||||
{
|
||||
"active_model": "devstral-2",
|
||||
"providers": [
|
||||
{
|
||||
"name": "mistral",
|
||||
"api_base": "https://api.mistral.ai/v1",
|
||||
"api_key_env_var": "MISTRAL_API_KEY",
|
||||
"reasoning_field_name": "thoughts",
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"name": "mistral-vibe-cli-latest",
|
||||
"provider": "mistral",
|
||||
"alias": "devstral-2",
|
||||
}
|
||||
],
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
reset_harness_files_manager()
|
||||
init_harness_files_manager("user")
|
||||
|
||||
context = OnboardingContext.load()
|
||||
|
||||
assert context.provider.name == "mistral"
|
||||
assert context.provider.backend.value == "generic"
|
||||
assert context.provider.reasoning_field_name == "thoughts"
|
||||
assert (
|
||||
context.provider.browser_auth_base_url
|
||||
== DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL
|
||||
)
|
||||
assert (
|
||||
context.provider.browser_auth_api_base_url
|
||||
== DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL
|
||||
)
|
||||
assert context.supports_browser_sign_in is True
|
||||
|
||||
def test_legacy_explicit_mistral_provider_backfills_only_missing_browser_auth_url(
|
||||
self,
|
||||
) -> None:
|
||||
provider = ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
browser_auth_base_url="https://custom-console.example",
|
||||
)
|
||||
|
||||
assert provider.backend.value == "generic"
|
||||
assert provider.browser_auth_base_url == "https://custom-console.example"
|
||||
assert (
|
||||
provider.browser_auth_api_base_url
|
||||
== DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL
|
||||
)
|
||||
assert provider.supports_browser_sign_in is True
|
||||
|
||||
def test_legacy_mistral_provider_keeps_browser_sign_in_after_round_trip(
|
||||
self,
|
||||
) -> None:
|
||||
provider = ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
)
|
||||
|
||||
reloaded_provider = ProviderConfig.model_validate(
|
||||
provider.model_dump(mode="json")
|
||||
)
|
||||
|
||||
assert reloaded_provider.supports_browser_sign_in is True
|
||||
|
||||
def test_explicit_generic_mistral_provider_does_not_get_browser_auth_defaults(
|
||||
self,
|
||||
) -> None:
|
||||
provider = ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.GENERIC,
|
||||
)
|
||||
|
||||
assert provider.backend.value == "generic"
|
||||
assert provider.browser_auth_base_url is None
|
||||
assert provider.browser_auth_api_base_url is None
|
||||
assert provider.supports_browser_sign_in is False
|
||||
|
||||
def test_custom_provider_browser_auth_urls_round_trip(self) -> None:
|
||||
custom_provider = _custom_provider(
|
||||
browser_auth_base_url="https://custom.example/sign-in",
|
||||
browser_auth_api_base_url="https://custom.example/api",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
cfg = build_test_vibe_config(
|
||||
active_model="custom-model",
|
||||
providers=[custom_provider],
|
||||
models=[_custom_model()],
|
||||
)
|
||||
|
||||
dumped = cfg.model_dump(mode="json")
|
||||
reloaded_provider = ProviderConfig.model_validate(dumped["providers"][0])
|
||||
|
||||
assert (
|
||||
reloaded_provider.browser_auth_base_url == "https://custom.example/sign-in"
|
||||
)
|
||||
assert (
|
||||
reloaded_provider.browser_auth_api_base_url == "https://custom.example/api"
|
||||
)
|
||||
assert reloaded_provider.supports_browser_sign_in is True
|
||||
|
||||
def test_custom_mistral_provider_without_browser_auth_urls_is_not_capable(
|
||||
self,
|
||||
) -> None:
|
||||
provider = _custom_provider(backend=Backend.MISTRAL)
|
||||
|
||||
assert provider.browser_auth_base_url is None
|
||||
assert provider.browser_auth_api_base_url is None
|
||||
assert provider.supports_browser_sign_in is False
|
||||
|
||||
def test_non_mistral_provider_with_browser_auth_urls_is_not_capable(self) -> None:
|
||||
provider = _custom_provider(
|
||||
browser_auth_base_url="https://custom.example/sign-in",
|
||||
browser_auth_api_base_url="https://custom.example/api",
|
||||
)
|
||||
|
||||
assert provider.supports_browser_sign_in is False
|
||||
|
||||
|
||||
class TestOnboardingContextResolution:
|
||||
def test_load_uses_explicit_overrides_when_harness_manager_is_uninitialized(
|
||||
self,
|
||||
) -> None:
|
||||
reset_harness_files_manager()
|
||||
|
||||
context = OnboardingContext.load(
|
||||
active_model="custom-model",
|
||||
providers=[_custom_provider_payload()],
|
||||
models=[_custom_model_payload()],
|
||||
)
|
||||
|
||||
assert context.provider.name == "custom-provider"
|
||||
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
|
||||
|
||||
def test_load_uses_env_overrides(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
reset_harness_files_manager()
|
||||
monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model")
|
||||
monkeypatch.setenv(
|
||||
"VIBE_PROVIDERS",
|
||||
json.dumps([
|
||||
{
|
||||
"name": "env-provider",
|
||||
"api_base": "https://env.example/v1",
|
||||
"api_key_env_var": "ENV_API_KEY",
|
||||
}
|
||||
]),
|
||||
)
|
||||
monkeypatch.setenv(
|
||||
"VIBE_MODELS",
|
||||
json.dumps([
|
||||
{"name": "env-model", "provider": "env-provider", "alias": "env-model"}
|
||||
]),
|
||||
)
|
||||
|
||||
context = OnboardingContext.load()
|
||||
|
||||
assert context.provider.name == "env-provider"
|
||||
assert context.provider.api_key_env_var == "ENV_API_KEY"
|
||||
|
||||
def test_load_prefers_explicit_overrides_over_toml_and_env(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
|
||||
config_file = tmp_path / "config.toml"
|
||||
with config_file.open("wb") as file:
|
||||
tomli_w.dump(
|
||||
{
|
||||
"active_model": "toml-model",
|
||||
"providers": [
|
||||
{
|
||||
"name": "toml-provider",
|
||||
"api_base": "https://toml.example/v1",
|
||||
"api_key_env_var": "TOML_API_KEY",
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"name": "toml-model",
|
||||
"provider": "toml-provider",
|
||||
"alias": "toml-model",
|
||||
}
|
||||
],
|
||||
},
|
||||
file,
|
||||
)
|
||||
monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model")
|
||||
monkeypatch.setenv(
|
||||
"VIBE_PROVIDERS",
|
||||
json.dumps([
|
||||
{
|
||||
"name": "env-provider",
|
||||
"api_base": "https://env.example/v1",
|
||||
"api_key_env_var": "ENV_API_KEY",
|
||||
}
|
||||
]),
|
||||
)
|
||||
monkeypatch.setenv(
|
||||
"VIBE_MODELS",
|
||||
json.dumps([
|
||||
{"name": "env-model", "provider": "env-provider", "alias": "env-model"}
|
||||
]),
|
||||
)
|
||||
|
||||
reset_harness_files_manager()
|
||||
init_harness_files_manager("user")
|
||||
|
||||
context = OnboardingContext.load(
|
||||
active_model="custom-model",
|
||||
providers=[_custom_provider_payload()],
|
||||
models=[_custom_model_payload()],
|
||||
)
|
||||
|
||||
assert context.provider.name == "custom-provider"
|
||||
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
|
||||
|
||||
def test_load_accepts_typed_provider_and_model_overrides(self) -> None:
|
||||
context = OnboardingContext.load(
|
||||
active_model="custom-model",
|
||||
providers=[_custom_provider(api_key_env_var="CUSTOM_API_KEY")],
|
||||
models=[_custom_model()],
|
||||
)
|
||||
|
||||
assert context.provider.name == "custom-provider"
|
||||
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
|
||||
|
||||
def test_load_preserves_explicit_overrides_when_onboarding_toml_is_invalid(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text("invalid = [", encoding="utf-8")
|
||||
|
||||
reset_harness_files_manager()
|
||||
init_harness_files_manager("user")
|
||||
|
||||
context = OnboardingContext.load(
|
||||
active_model="custom-model",
|
||||
providers=[_custom_provider_payload()],
|
||||
models=[_custom_model_payload()],
|
||||
)
|
||||
|
||||
assert context.provider.name == "custom-provider"
|
||||
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
|
||||
|
||||
def test_load_preserves_explicit_provider_and_model_overrides_when_toml_is_invalid(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text("invalid = [", encoding="utf-8")
|
||||
|
||||
reset_harness_files_manager()
|
||||
init_harness_files_manager("user")
|
||||
|
||||
context = OnboardingContext.load(
|
||||
providers=[_custom_provider_payload()],
|
||||
models=[_custom_model_payload(alias="devstral-2")],
|
||||
)
|
||||
|
||||
assert context.provider.name == "custom-provider"
|
||||
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
|
||||
|
||||
def test_load_preserves_explicit_provider_override_when_toml_is_invalid(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text("invalid = [", encoding="utf-8")
|
||||
|
||||
reset_harness_files_manager()
|
||||
init_harness_files_manager("user")
|
||||
|
||||
context = OnboardingContext.load(
|
||||
active_model="custom-model", providers=[_custom_provider_payload()]
|
||||
)
|
||||
|
||||
assert context.provider.name == "custom-provider"
|
||||
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
|
||||
|
||||
def test_load_preserves_explicit_overrides_when_onboarding_env_is_invalid(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
reset_harness_files_manager()
|
||||
monkeypatch.setenv("VIBE_PROVIDERS", "not-json")
|
||||
|
||||
context = OnboardingContext.load(
|
||||
active_model="custom-model",
|
||||
providers=[_custom_provider_payload()],
|
||||
models=[_custom_model_payload()],
|
||||
)
|
||||
|
||||
assert context.provider.name == "custom-provider"
|
||||
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
|
||||
|
||||
def test_load_preserves_explicit_provider_override_when_onboarding_env_is_invalid(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
reset_harness_files_manager()
|
||||
monkeypatch.setenv("VIBE_MODELS", "not-json")
|
||||
|
||||
context = OnboardingContext.load(
|
||||
active_model="custom-model", providers=[_custom_provider_payload()]
|
||||
)
|
||||
|
||||
assert context.provider.name == "custom-provider"
|
||||
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
|
||||
|
||||
def test_load_uses_valid_active_provider_when_unrelated_provider_is_malformed(
|
||||
self,
|
||||
) -> None:
|
||||
context = OnboardingContext.load(
|
||||
active_model="custom-model",
|
||||
providers=[
|
||||
_custom_provider_payload(),
|
||||
{"name": "broken-provider", "backend": "mistral"},
|
||||
],
|
||||
models=[_custom_model_payload()],
|
||||
)
|
||||
|
||||
assert context.provider.name == "custom-provider"
|
||||
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
|
||||
|
||||
def test_load_uses_valid_active_provider_when_unrelated_model_is_malformed(
|
||||
self,
|
||||
) -> None:
|
||||
context = OnboardingContext.load(
|
||||
active_model="custom-model",
|
||||
providers=[_custom_provider_payload()],
|
||||
models=[
|
||||
_custom_model_payload(),
|
||||
{"name": "broken-model", "alias": "broken-model"},
|
||||
],
|
||||
)
|
||||
|
||||
assert context.provider.name == "custom-provider"
|
||||
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
|
||||
|
||||
def test_load_uses_single_valid_provider_when_no_matching_model_exists(
|
||||
self,
|
||||
) -> None:
|
||||
context = OnboardingContext.load(
|
||||
active_model="custom-model", providers=[_custom_provider_payload()]
|
||||
)
|
||||
|
||||
assert context.provider.name == "custom-provider"
|
||||
assert context.provider.api_key_env_var == "CUSTOM_API_KEY"
|
||||
|
||||
def test_load_preserves_browser_sign_in_for_valid_active_provider_with_unrelated_invalid_entry(
|
||||
self,
|
||||
) -> None:
|
||||
context = OnboardingContext.load(
|
||||
active_model="custom-model",
|
||||
providers=[
|
||||
_custom_provider_payload(
|
||||
browser_auth_base_url="https://custom.example/sign-in",
|
||||
browser_auth_api_base_url="https://custom.example/api",
|
||||
backend="mistral",
|
||||
),
|
||||
{"name": "broken-provider", "backend": "mistral"},
|
||||
],
|
||||
models=[_custom_model_payload()],
|
||||
)
|
||||
|
||||
assert context.provider.name == "custom-provider"
|
||||
assert context.supports_browser_sign_in is True
|
||||
|
||||
def test_load_falls_back_when_active_provider_is_invalid(self) -> None:
|
||||
context = OnboardingContext.load(
|
||||
active_model="custom-model",
|
||||
providers=[{"name": "custom-provider", "backend": "mistral"}],
|
||||
models=[_custom_model_payload()],
|
||||
)
|
||||
|
||||
assert context.provider.name == "mistral"
|
||||
|
||||
def test_load_falls_back_when_no_valid_provider_model_pair_exists(self) -> None:
|
||||
context = OnboardingContext.load(
|
||||
active_model="broken-model",
|
||||
providers=[{"name": "broken-provider", "backend": "mistral"}],
|
||||
models=[{"name": "broken-model", "alias": "broken-model"}],
|
||||
)
|
||||
|
||||
assert context.provider.name == "mistral"
|
||||
|
||||
def test_load_falls_back_when_onboarding_toml_is_invalid(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text("invalid = [", encoding="utf-8")
|
||||
|
||||
reset_harness_files_manager()
|
||||
init_harness_files_manager("user")
|
||||
|
||||
context = OnboardingContext.load()
|
||||
|
||||
assert context.provider.name == "mistral"
|
||||
|
||||
def test_load_falls_back_when_onboarding_env_payload_is_invalid(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
reset_harness_files_manager()
|
||||
monkeypatch.setenv("VIBE_PROVIDERS", "not-json")
|
||||
|
||||
context = OnboardingContext.load()
|
||||
|
||||
assert context.provider.name == "mistral"
|
||||
|
||||
|
||||
class TestCompactionModel:
|
||||
def test_get_compaction_model_returns_active_when_unset(self) -> None:
|
||||
cfg = build_test_vibe_config()
|
||||
@@ -224,3 +802,68 @@ class TestCompactionModel:
|
||||
cfg = build_test_vibe_config()
|
||||
dumped = cfg.model_dump()
|
||||
assert "compaction_model" not in dumped
|
||||
|
||||
|
||||
class TestGetMistralProvider:
|
||||
def test_returns_active_provider_when_it_is_mistral(self) -> None:
|
||||
cfg = build_test_vibe_config()
|
||||
provider = cfg.get_mistral_provider()
|
||||
active = cfg.get_provider_for_model(cfg.get_active_model())
|
||||
assert provider is active
|
||||
assert provider is not None
|
||||
assert provider.backend == Backend.MISTRAL
|
||||
|
||||
def test_falls_back_to_first_mistral_provider_when_active_is_not_mistral(
|
||||
self,
|
||||
) -> None:
|
||||
mistral_provider = ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
llamacpp_provider = ProviderConfig(
|
||||
name="llamacpp", api_base="http://127.0.0.1:8080/v1", api_key_env_var=""
|
||||
)
|
||||
llamacpp_model = ModelConfig(
|
||||
name="llama-local", provider="llamacpp", alias="llama-local"
|
||||
)
|
||||
cfg = build_test_vibe_config(
|
||||
providers=[llamacpp_provider, mistral_provider],
|
||||
models=[llamacpp_model],
|
||||
active_model="llama-local",
|
||||
)
|
||||
provider = cfg.get_mistral_provider()
|
||||
assert provider is mistral_provider
|
||||
|
||||
def test_returns_none_when_no_mistral_provider(self) -> None:
|
||||
llamacpp_provider = ProviderConfig(
|
||||
name="llamacpp", api_base="http://127.0.0.1:8080/v1", api_key_env_var=""
|
||||
)
|
||||
llamacpp_model = ModelConfig(
|
||||
name="llama-local", provider="llamacpp", alias="llama-local"
|
||||
)
|
||||
cfg = build_test_vibe_config(
|
||||
providers=[llamacpp_provider],
|
||||
models=[llamacpp_model],
|
||||
active_model="llama-local",
|
||||
)
|
||||
assert cfg.get_mistral_provider() is None
|
||||
|
||||
def test_falls_back_to_iterating_when_active_model_is_misconfigured(self) -> None:
|
||||
mistral_provider = ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
llamacpp_model = ModelConfig(
|
||||
name="llama-local", provider="llamacpp", alias="llama-local"
|
||||
)
|
||||
cfg = build_test_vibe_config(
|
||||
providers=[mistral_provider],
|
||||
models=[llamacpp_model],
|
||||
active_model="llama-local",
|
||||
)
|
||||
provider = cfg.get_mistral_provider()
|
||||
assert provider is mistral_provider
|
||||
|
||||
@@ -5,131 +5,164 @@ from pathlib import Path
|
||||
from vibe.core.paths._local_config_walk import (
|
||||
_MAX_DIRS,
|
||||
WALK_MAX_DEPTH,
|
||||
has_config_dirs_nearby,
|
||||
walk_local_config_dirs_all,
|
||||
walk_local_config_dirs,
|
||||
)
|
||||
|
||||
|
||||
class TestBoundedWalk:
|
||||
class TestWalkTools:
|
||||
def test_finds_config_at_root(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
tools, skills, agents = walk_local_config_dirs_all(tmp_path)
|
||||
assert tmp_path / ".vibe" / "tools" in tools
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert tmp_path.resolve() / ".vibe" / "tools" in result.tools
|
||||
|
||||
def test_finds_config_within_depth_limit(self, tmp_path: Path) -> None:
|
||||
nested = tmp_path
|
||||
for i in range(WALK_MAX_DEPTH):
|
||||
nested = nested / f"level{i}"
|
||||
(nested / ".vibe" / "skills").mkdir(parents=True)
|
||||
_, skills, _ = walk_local_config_dirs_all(tmp_path)
|
||||
assert nested / ".vibe" / "skills" in skills
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert nested.resolve() / ".vibe" / "skills" in result.skills
|
||||
|
||||
def test_does_not_find_config_beyond_depth_limit(self, tmp_path: Path) -> None:
|
||||
nested = tmp_path
|
||||
for i in range(WALK_MAX_DEPTH + 1):
|
||||
nested = nested / f"level{i}"
|
||||
(nested / ".vibe" / "tools").mkdir(parents=True)
|
||||
tools, skills, agents = walk_local_config_dirs_all(tmp_path)
|
||||
assert not tools
|
||||
assert not skills
|
||||
assert not agents
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert not result.tools
|
||||
assert not result.skills
|
||||
assert not result.agents
|
||||
|
||||
def test_respects_dir_count_limit(self, tmp_path: Path) -> None:
|
||||
# Create more directories than _MAX_DIRS at depth 1
|
||||
for i in range(_MAX_DIRS + 10):
|
||||
(tmp_path / f"dir{i:05d}").mkdir()
|
||||
# Place config in a directory that would be scanned late
|
||||
(tmp_path / "zzz_last" / ".vibe" / "tools").mkdir(parents=True)
|
||||
|
||||
tools, _, _ = walk_local_config_dirs_all(tmp_path)
|
||||
# The walk should stop before visiting all dirs.
|
||||
# Whether zzz_last is found depends on sort order and limit,
|
||||
# but total visited dirs should be bounded.
|
||||
# We just verify no crash and the function returns.
|
||||
assert isinstance(tools, tuple)
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert isinstance(result.tools, tuple)
|
||||
|
||||
def test_skips_ignored_directories(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "node_modules" / ".vibe" / "tools").mkdir(parents=True)
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
tools, _, _ = walk_local_config_dirs_all(tmp_path)
|
||||
assert tools == (tmp_path / ".vibe" / "tools",)
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert result.tools == (tmp_path.resolve() / ".vibe" / "tools",)
|
||||
|
||||
def test_skips_dot_directories(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".hidden" / ".vibe" / "tools").mkdir(parents=True)
|
||||
tools, _, _ = walk_local_config_dirs_all(tmp_path)
|
||||
assert not tools
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert not result.tools
|
||||
|
||||
def test_preserves_alphabetical_ordering(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "bbb" / ".vibe" / "tools").mkdir(parents=True)
|
||||
(tmp_path / "aaa" / ".vibe" / "tools").mkdir(parents=True)
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
tools, _, _ = walk_local_config_dirs_all(tmp_path)
|
||||
assert tools == (
|
||||
tmp_path / ".vibe" / "tools",
|
||||
tmp_path / "aaa" / ".vibe" / "tools",
|
||||
tmp_path / "bbb" / ".vibe" / "tools",
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
resolved = tmp_path.resolve()
|
||||
assert result.tools == (
|
||||
resolved / ".vibe" / "tools",
|
||||
resolved / "aaa" / ".vibe" / "tools",
|
||||
resolved / "bbb" / ".vibe" / "tools",
|
||||
)
|
||||
|
||||
def test_finds_agents_skills(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".agents" / "skills").mkdir(parents=True)
|
||||
_, skills, _ = walk_local_config_dirs_all(tmp_path)
|
||||
assert tmp_path / ".agents" / "skills" in skills
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert tmp_path.resolve() / ".agents" / "skills" in result.skills
|
||||
|
||||
def test_finds_all_config_types(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
|
||||
(tmp_path / ".vibe" / "agents").mkdir(parents=True)
|
||||
(tmp_path / ".agents" / "skills").mkdir(parents=True)
|
||||
tools, skills, agents = walk_local_config_dirs_all(tmp_path)
|
||||
assert tmp_path / ".vibe" / "tools" in tools
|
||||
assert tmp_path / ".vibe" / "skills" in skills
|
||||
assert tmp_path / ".vibe" / "agents" in agents
|
||||
assert tmp_path / ".agents" / "skills" in skills
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
resolved = tmp_path.resolve()
|
||||
assert resolved / ".vibe" / "tools" in result.tools
|
||||
assert resolved / ".vibe" / "skills" in result.skills
|
||||
assert resolved / ".vibe" / "agents" in result.agents
|
||||
assert resolved / ".agents" / "skills" in result.skills
|
||||
|
||||
|
||||
class TestHasConfigDirsNearby:
|
||||
def test_returns_true_when_vibe_tools_exist(self, tmp_path: Path) -> None:
|
||||
class TestWalkConfigDirs:
|
||||
def test_finds_vibe_with_tools(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert tmp_path.resolve() / ".vibe" in result.config_dirs
|
||||
|
||||
def test_returns_true_when_vibe_skills_exist(self, tmp_path: Path) -> None:
|
||||
def test_finds_vibe_with_skills(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert tmp_path.resolve() / ".vibe" in result.config_dirs
|
||||
|
||||
def test_returns_true_when_agents_skills_exist(self, tmp_path: Path) -> None:
|
||||
def test_finds_agents_with_skills(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".agents" / "skills").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert tmp_path.resolve() / ".agents" in result.config_dirs
|
||||
|
||||
def test_returns_false_when_empty(self, tmp_path: Path) -> None:
|
||||
assert has_config_dirs_nearby(tmp_path) is False
|
||||
|
||||
def test_returns_false_for_vibe_dir_without_subdirs(self, tmp_path: Path) -> None:
|
||||
def test_ignores_empty_vibe_dir(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe").mkdir()
|
||||
assert has_config_dirs_nearby(tmp_path) is False
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert result.config_dirs == ()
|
||||
|
||||
def test_returns_true_for_shallow_nested(self, tmp_path: Path) -> None:
|
||||
def test_ignores_empty_agents_dir(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".agents").mkdir()
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert result.config_dirs == ()
|
||||
|
||||
def test_returns_empty_when_empty(self, tmp_path: Path) -> None:
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert result.config_dirs == ()
|
||||
|
||||
def test_finds_shallow_nested(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "sub" / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert tmp_path.resolve() / "sub" / ".vibe" in result.config_dirs
|
||||
|
||||
def test_returns_true_at_depth_2(self, tmp_path: Path) -> None:
|
||||
def test_finds_at_depth_2(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "a" / "b" / ".agents" / "skills").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert tmp_path.resolve() / "a" / "b" / ".agents" in result.config_dirs
|
||||
|
||||
def test_returns_false_beyond_default_depth(self, tmp_path: Path) -> None:
|
||||
def test_returns_empty_beyond_default_depth(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "a" / "b" / "c" / "d" / "e" / ".vibe" / "tools").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is False
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert result.config_dirs == ()
|
||||
|
||||
def test_custom_depth(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "a" / "b" / "c" / "d" / "e" / ".vibe" / "tools").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path, max_depth=5) is True
|
||||
result = walk_local_config_dirs(tmp_path, max_depth=5)
|
||||
assert (
|
||||
tmp_path.resolve() / "a" / "b" / "c" / "d" / "e" / ".vibe"
|
||||
in result.config_dirs
|
||||
)
|
||||
|
||||
def test_early_exit_on_first_match(self, tmp_path: Path) -> None:
|
||||
# Create many dirs but put config early; function should return quickly
|
||||
def test_finds_match_among_many_dirs(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
for i in range(100):
|
||||
(tmp_path / f"dir{i}").mkdir()
|
||||
assert has_config_dirs_nearby(tmp_path) is True
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert tmp_path.resolve() / ".vibe" in result.config_dirs
|
||||
|
||||
def test_skips_ignored_directories(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_config_dirs_nearby(tmp_path) is False
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert result.config_dirs == ()
|
||||
|
||||
def test_finds_vibe_with_prompts(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "prompts").mkdir(parents=True)
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert tmp_path.resolve() / ".vibe" in result.config_dirs
|
||||
|
||||
def test_finds_vibe_with_config_toml(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe").mkdir()
|
||||
(tmp_path / ".vibe" / "config.toml").write_text("")
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
assert tmp_path.resolve() / ".vibe" in result.config_dirs
|
||||
|
||||
def test_finds_multiple_config_dirs(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
|
||||
(tmp_path / ".agents" / "skills").mkdir(parents=True)
|
||||
(tmp_path / "sub" / ".vibe" / "tools").mkdir(parents=True)
|
||||
result = walk_local_config_dirs(tmp_path)
|
||||
resolved = tmp_path.resolve()
|
||||
assert resolved / ".vibe" in result.config_dirs
|
||||
assert resolved / ".agents" in result.config_dirs
|
||||
assert resolved / "sub" / ".vibe" in result.config_dirs
|
||||
|
||||
@@ -724,3 +724,46 @@ def test_working_completed_with_tool_call_id_emits_error_result() -> None:
|
||||
assert len(result_events) == 1
|
||||
assert result_events[0].error is not None
|
||||
assert result_events[0].tool_call_id == "call-write-err"
|
||||
|
||||
|
||||
def test_json_patch_with_array_index_preserves_list_structure() -> None:
|
||||
loop = _make_loop()
|
||||
started = _started(
|
||||
"msg-1",
|
||||
"assistant_message",
|
||||
{"contentChunks": [{"type": "text", "text": "Hello"}]},
|
||||
)
|
||||
in_progress = _in_progress(
|
||||
"msg-1",
|
||||
"assistant_message",
|
||||
[JSONPatchReplace(path="/contentChunks/0/text", value="Hello world")],
|
||||
)
|
||||
|
||||
loop._consume_workflow_event(started)
|
||||
progress_events = loop._consume_workflow_event(in_progress)
|
||||
|
||||
assert len(progress_events) == 1
|
||||
assert isinstance(progress_events[0], AssistantEvent)
|
||||
assert progress_events[0].content == " world"
|
||||
|
||||
|
||||
def test_steer_input_events_are_suppressed() -> None:
|
||||
loop = _make_loop()
|
||||
steer_started = _started(
|
||||
"steer-1",
|
||||
"wait_for_input",
|
||||
{"input_schema": {"title": "ChatInput"}, "label": "Send a message to steer..."},
|
||||
)
|
||||
steer_completed = _completed(
|
||||
"steer-1",
|
||||
"wait_for_input",
|
||||
{
|
||||
"input_schema": {"title": "ChatInput"},
|
||||
"label": "Send a message to steer...",
|
||||
"input": None,
|
||||
},
|
||||
)
|
||||
|
||||
assert loop._consume_workflow_event(steer_started) == []
|
||||
assert loop._consume_workflow_event(steer_completed) == []
|
||||
assert loop._translator.pending_input_request is None
|
||||
|
||||
34
tests/core/test_search_replace_encoding.py
Normal file
34
tests/core/test_search_replace_encoding.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.mock.utils import collect_result
|
||||
from vibe.core.tools.base import BaseToolState
|
||||
from vibe.core.tools.builtins.search_replace import (
|
||||
SearchReplace,
|
||||
SearchReplaceArgs,
|
||||
SearchReplaceConfig,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_replace_rewrites_with_detected_encoding(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
path = tmp_path / "utf16.txt"
|
||||
original = "line one café\nline two été\n"
|
||||
path.write_bytes(original.encode("utf-16"))
|
||||
|
||||
tool = SearchReplace(
|
||||
config_getter=lambda: SearchReplaceConfig(), state=BaseToolState()
|
||||
)
|
||||
patch = "<<<<<<< SEARCH\nline one café\n=======\nLINE ONE CAFÉ\n>>>>>>> REPLACE"
|
||||
await collect_result(
|
||||
tool.run(SearchReplaceArgs(file_path=str(path), content=patch))
|
||||
)
|
||||
|
||||
assert path.read_bytes().startswith(b"\xff\xfe")
|
||||
assert path.read_text(encoding="utf-16") == "LINE ONE CAFÉ\nline two été\n"
|
||||
@@ -10,7 +10,7 @@ from tests.conftest import build_test_vibe_config
|
||||
from tests.stubs.fake_tool import FakeTool, FakeToolArgs
|
||||
from vibe.core.agent_loop import ToolDecision, ToolExecutionResponse
|
||||
from vibe.core.llm.format import ResolvedToolCall
|
||||
from vibe.core.telemetry.send import DATALAKE_EVENTS_URL, TelemetryClient
|
||||
from vibe.core.telemetry.send import TelemetryClient
|
||||
from vibe.core.tools.base import BaseTool, ToolPermission
|
||||
from vibe.core.types import Backend
|
||||
from vibe.core.utils import get_user_agent
|
||||
@@ -44,6 +44,13 @@ def _run_telemetry_tasks() -> None:
|
||||
|
||||
|
||||
class TestTelemetryClient:
|
||||
def test_send_telemetry_event_swallows_config_getter_value_error(self) -> None:
|
||||
def _raise_config_error() -> Any:
|
||||
raise ValueError("config not ready")
|
||||
|
||||
client = TelemetryClient(config_getter=_raise_config_error)
|
||||
client.send_telemetry_event("vibe.test", {})
|
||||
|
||||
def test_send_telemetry_event_does_nothing_when_api_key_is_none(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -104,7 +111,7 @@ class TestTelemetryClient:
|
||||
await client.aclose()
|
||||
|
||||
mock_post.assert_called_once_with(
|
||||
DATALAKE_EVENTS_URL,
|
||||
"https://api.mistral.ai/v1/datalake/events",
|
||||
json={"event": "vibe.test_event", "properties": {"key": "value"}},
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
@@ -302,7 +309,7 @@ class TestTelemetryClient:
|
||||
await client.aclose()
|
||||
|
||||
mock_post.assert_called_once_with(
|
||||
DATALAKE_EVENTS_URL,
|
||||
"https://api.mistral.ai/v1/datalake/events",
|
||||
json={
|
||||
"event": "vibe.test_event",
|
||||
"properties": {"session_id": session_id, "key": "value"},
|
||||
@@ -336,7 +343,7 @@ class TestTelemetryClient:
|
||||
await client.aclose()
|
||||
|
||||
mock_post.assert_called_once_with(
|
||||
DATALAKE_EVENTS_URL,
|
||||
"https://api.mistral.ai/v1/datalake/events",
|
||||
json={"event": "vibe.test_event", "properties": {"key": "value"}},
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
@@ -414,3 +421,109 @@ class TestTelemetryClient:
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
assert "correlation_id" not in telemetry_events[0]
|
||||
|
||||
def test_telemetry_url_custom_provider_config(self) -> None:
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
custom_api_base = "https://api.custom.host/v2"
|
||||
|
||||
config = build_test_vibe_config(
|
||||
enable_telemetry=True,
|
||||
providers=[
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base=custom_api_base,
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
],
|
||||
)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
assert (
|
||||
client._get_telemetry_url(custom_api_base)
|
||||
== "https://api.custom.host/v1/datalake/events"
|
||||
)
|
||||
|
||||
def test_telemetry_url_preserves_port_in_api_base(self) -> None:
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
custom_api_base = "http://api.custom.host:8080/v1/"
|
||||
|
||||
config = build_test_vibe_config(
|
||||
enable_telemetry=True,
|
||||
providers=[
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base=custom_api_base,
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
],
|
||||
)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
assert (
|
||||
client._get_telemetry_url(custom_api_base)
|
||||
== "http://api.custom.host:8080/v1/datalake/events"
|
||||
)
|
||||
|
||||
def test_telemetry_url_falls_back_to_default_when_api_base_malformed(self) -> None:
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
config = build_test_vibe_config(
|
||||
enable_telemetry=True,
|
||||
providers=[
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="not-a-valid-url",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
],
|
||||
)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
assert (
|
||||
client._get_telemetry_url("not-a-valid-url")
|
||||
== "https://codestral.mistral.ai/v1/datalake/events"
|
||||
)
|
||||
|
||||
def test_is_active_false_when_mistral_provider_exists_but_no_api_key(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
env_key = config.get_provider_for_model(
|
||||
config.get_active_model()
|
||||
).api_key_env_var
|
||||
monkeypatch.delenv(env_key, raising=False)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
assert client.is_active() is False
|
||||
|
||||
def test_is_active_false_when_no_mistral_provider(self) -> None:
|
||||
from vibe.core.config import ProviderConfig
|
||||
|
||||
config = build_test_vibe_config(
|
||||
enable_telemetry=True,
|
||||
providers=[
|
||||
ProviderConfig(
|
||||
name="llamacpp",
|
||||
api_base="http://127.0.0.1:8080/v1",
|
||||
api_key_env_var="",
|
||||
)
|
||||
],
|
||||
)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
assert client.is_active() is False
|
||||
|
||||
def test_is_active_false_when_config_getter_raises_value_error(self) -> None:
|
||||
def _raise_config_error() -> Any:
|
||||
raise ValueError("config not ready")
|
||||
|
||||
client = TelemetryClient(config_getter=_raise_config_error)
|
||||
assert client.is_active() is False
|
||||
|
||||
@@ -129,7 +129,7 @@ class TestGitRepositoryGetInfo:
|
||||
async def test_raises_when_not_git_repo(self, tmp_path: Path) -> None:
|
||||
repo = GitRepository(tmp_path)
|
||||
with pytest.raises(
|
||||
ServiceTeleportNotSupportedError, match="Not a git repository"
|
||||
ServiceTeleportNotSupportedError, match="Teleport requires a git repository"
|
||||
):
|
||||
await repo.get_info()
|
||||
|
||||
|
||||
@@ -185,7 +185,9 @@ class TestTeleportServiceCheckSupported:
|
||||
self, service: TeleportService
|
||||
) -> None:
|
||||
service._git.get_info = AsyncMock(
|
||||
side_effect=ServiceTeleportNotSupportedError("Not a git repository")
|
||||
side_effect=ServiceTeleportNotSupportedError(
|
||||
"Teleport requires a git repository. cd into a project with a .git directory and try again."
|
||||
)
|
||||
)
|
||||
with pytest.raises(ServiceTeleportNotSupportedError):
|
||||
await service.check_supported()
|
||||
|
||||
@@ -10,8 +10,8 @@ import tomli_w
|
||||
from vibe.core.paths import AGENTS_MD_FILENAME, TRUSTED_FOLDERS_FILE
|
||||
from vibe.core.trusted_folders import (
|
||||
TrustedFoldersManager,
|
||||
find_trustable_files,
|
||||
has_agents_md_file,
|
||||
has_trustable_content,
|
||||
)
|
||||
|
||||
|
||||
@@ -319,34 +319,69 @@ class TestHasAgentsMdFile:
|
||||
assert AGENTS_MD_FILENAME == "AGENTS.md"
|
||||
|
||||
|
||||
class TestHasTrustableContent:
|
||||
def test_returns_true_when_vibe_dir_exists(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_trustable_content(tmp_path) is True
|
||||
class TestFindTrustableFiles:
|
||||
def test_returns_empty_for_clean_directory(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
assert find_trustable_files(tmp_path) == []
|
||||
|
||||
def test_returns_true_when_agents_dir_exists(self, tmp_path: Path) -> None:
|
||||
def test_detects_vibe_dir(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
result = find_trustable_files(tmp_path)
|
||||
assert ".vibe/" in result
|
||||
|
||||
def test_detects_agents_dir(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".agents" / "skills").mkdir(parents=True)
|
||||
assert has_trustable_content(tmp_path) is True
|
||||
result = find_trustable_files(tmp_path)
|
||||
assert ".agents/" in result
|
||||
|
||||
def test_returns_true_when_agents_md_filename_exists(self, tmp_path: Path) -> None:
|
||||
(tmp_path / AGENTS_MD_FILENAME).write_text("", encoding="utf-8")
|
||||
assert has_trustable_content(tmp_path) is True
|
||||
(tmp_path / AGENTS_MD_FILENAME).unlink()
|
||||
def test_ignores_empty_vibe_dir(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe").mkdir()
|
||||
assert find_trustable_files(tmp_path) == []
|
||||
|
||||
def test_returns_false_when_no_trustable_content(self, tmp_path: Path) -> None:
|
||||
def test_ignores_empty_agents_dir(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".agents").mkdir()
|
||||
assert find_trustable_files(tmp_path) == []
|
||||
|
||||
def test_detects_agents_md(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "AGENTS.md").write_text("# Agent", encoding="utf-8")
|
||||
result = find_trustable_files(tmp_path)
|
||||
assert "AGENTS.md" in result
|
||||
|
||||
def test_returns_empty_when_no_trustable_content(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "other.txt").write_text("", encoding="utf-8")
|
||||
assert has_trustable_content(tmp_path) is False
|
||||
assert find_trustable_files(tmp_path) == []
|
||||
|
||||
def test_returns_true_when_vibe_config_in_subfolder(self, tmp_path: Path) -> None:
|
||||
def test_detects_vibe_config_in_subfolder(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "sub" / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_trustable_content(tmp_path) is True
|
||||
result = find_trustable_files(tmp_path)
|
||||
assert "sub/.vibe/" in result
|
||||
|
||||
def test_returns_true_when_agents_skills_in_subfolder(self, tmp_path: Path) -> None:
|
||||
def test_detects_agents_skills_in_subfolder(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "deep" / "nested" / ".agents" / "skills").mkdir(parents=True)
|
||||
assert has_trustable_content(tmp_path) is True
|
||||
result = find_trustable_files(tmp_path)
|
||||
assert "deep/nested/.agents/" in result
|
||||
|
||||
def test_returns_false_when_config_only_inside_ignored_dir(
|
||||
def test_returns_empty_when_config_only_inside_ignored_dir(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
(tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True)
|
||||
assert has_trustable_content(tmp_path) is False
|
||||
assert find_trustable_files(tmp_path) == []
|
||||
|
||||
def test_detects_nested_vibe_dir(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "pkg" / ".vibe" / "tools").mkdir(parents=True)
|
||||
result = find_trustable_files(tmp_path)
|
||||
assert "pkg/.vibe/" in result
|
||||
|
||||
def test_detects_multiple_files(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "skills").mkdir(parents=True)
|
||||
(tmp_path / "AGENTS.md").write_text("# Agent", encoding="utf-8")
|
||||
(tmp_path / "sub" / ".agents" / "skills").mkdir(parents=True)
|
||||
result = find_trustable_files(tmp_path)
|
||||
assert ".vibe/" in result
|
||||
assert "AGENTS.md" in result
|
||||
assert "sub/.agents/" in result
|
||||
|
||||
def test_no_duplicates_for_root_vibe_dir(self, tmp_path: Path) -> None:
|
||||
(tmp_path / ".vibe" / "tools").mkdir(parents=True)
|
||||
result = find_trustable_files(tmp_path)
|
||||
assert result.count(".vibe/") == 1
|
||||
|
||||
@@ -5,7 +5,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from vibe.core.utils import get_server_url_from_api_base
|
||||
from vibe.core.utils.io import read_safe
|
||||
import vibe.core.utils.io as io_utils
|
||||
from vibe.core.utils.io import decode_safe, read_safe, read_safe_async
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -26,39 +27,90 @@ class TestReadSafe:
|
||||
def test_reads_utf8(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "hello.txt"
|
||||
f.write_text("café\n", encoding="utf-8")
|
||||
assert read_safe(f) == "café\n"
|
||||
assert read_safe(f).text == "café\n"
|
||||
assert decode_safe(f.read_bytes()).text == "café\n"
|
||||
|
||||
def test_falls_back_on_non_utf8(self, tmp_path: Path) -> None:
|
||||
def test_falls_back_on_non_utf8(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
f = tmp_path / "latin.txt"
|
||||
# \x81 invalid UTF-8 and undefined in CP1252 → U+FFFD on all platforms
|
||||
f.write_bytes(b"maf\x81\n")
|
||||
monkeypatch.setattr(io_utils, "_encoding_from_best_match", lambda _raw: None)
|
||||
result = read_safe(f)
|
||||
assert result == "maf<EFBFBD>\n"
|
||||
assert result.text == "maf<EFBFBD>\n"
|
||||
|
||||
def test_falls_back_to_detected_encoding(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
f = tmp_path / "utf16.txt"
|
||||
expected = "hello été\n"
|
||||
f.write_bytes(expected.encode("utf-16le"))
|
||||
monkeypatch.setattr(
|
||||
io_utils.locale, "getpreferredencoding", lambda _do_setlocale: "utf-8"
|
||||
)
|
||||
|
||||
assert read_safe(f).text == expected
|
||||
|
||||
def test_raise_on_error_true_utf8_succeeds(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "hello.txt"
|
||||
f.write_text("café\n", encoding="utf-8")
|
||||
assert read_safe(f, raise_on_error=True) == "café\n"
|
||||
assert read_safe(f, raise_on_error=True).text == "café\n"
|
||||
|
||||
def test_raise_on_error_true_non_utf8_raises(self, tmp_path: Path) -> None:
|
||||
def test_raise_on_error_true_non_utf8_raises(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
f = tmp_path / "bad.txt"
|
||||
# Invalid UTF-8; with raise_on_error=True we use default encoding (strict), so decode errors propagate
|
||||
f.write_bytes(b"maf\x81\n")
|
||||
assert read_safe(f, raise_on_error=False) == "maf<EFBFBD>\n"
|
||||
monkeypatch.setattr(io_utils, "_encoding_from_best_match", lambda _raw: None)
|
||||
assert read_safe(f, raise_on_error=False).text == "maf<EFBFBD>\n"
|
||||
with pytest.raises(UnicodeDecodeError):
|
||||
read_safe(f, raise_on_error=True)
|
||||
|
||||
def test_empty_file(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "empty.txt"
|
||||
f.write_bytes(b"")
|
||||
assert read_safe(f) == ""
|
||||
assert read_safe(f).text == ""
|
||||
|
||||
def test_binary_garbage_does_not_raise(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "garbage.bin"
|
||||
f.write_bytes(bytes(range(256)))
|
||||
result = read_safe(f)
|
||||
assert isinstance(result, str)
|
||||
assert isinstance(result.text, str)
|
||||
|
||||
def test_file_not_found_raises(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
read_safe(tmp_path / "nope.txt")
|
||||
|
||||
|
||||
class TestReadSafeResultEncoding:
|
||||
def test_reports_utf8_for_plain_utf8_file(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "x.txt"
|
||||
f.write_text("ok\n", encoding="utf-8")
|
||||
got = read_safe(f)
|
||||
assert got.text == "ok\n"
|
||||
assert got.encoding == "utf-8"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_reports_utf16_when_bom_present(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "u16.txt"
|
||||
f.write_bytes("a\n".encode("utf-16"))
|
||||
got = await read_safe_async(f)
|
||||
assert got.encoding == "utf-16-le"
|
||||
# utf-16-le leaves the BOM as U+FEFF in the string (unlike utf-8-sig).
|
||||
assert got.text == "\ufeffa\n"
|
||||
|
||||
|
||||
class TestReadSafeAsync:
|
||||
@pytest.mark.asyncio
|
||||
async def test_raise_on_error_final_utf8_strict_or_replace(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""raise_on_error controls strict vs replace on the last UTF-8 fallback."""
|
||||
f = tmp_path / "bad.txt"
|
||||
f.write_bytes(b"maf\x81\n")
|
||||
monkeypatch.setattr(io_utils, "_encoding_from_best_match", lambda _raw: None)
|
||||
assert (await read_safe_async(f, raise_on_error=False)).text == "maf<EFBFBD>\n"
|
||||
with pytest.raises(UnicodeDecodeError):
|
||||
await read_safe_async(f, raise_on_error=True)
|
||||
|
||||
@@ -5,12 +5,14 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.mock.utils import collect_result
|
||||
from vibe.core.config.harness_files import (
|
||||
init_harness_files_manager,
|
||||
reset_harness_files_manager,
|
||||
)
|
||||
from vibe.core.tools.builtins.read_file import (
|
||||
ReadFile,
|
||||
ReadFileArgs,
|
||||
ReadFileResult,
|
||||
ReadFileState,
|
||||
ReadFileToolConfig,
|
||||
@@ -37,6 +39,30 @@ def _make_read_file() -> ReadFile:
|
||||
return ReadFile(config_getter=lambda: ReadFileToolConfig(), state=ReadFileState())
|
||||
|
||||
|
||||
class TestReadFileExecution:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_large_offset_still_reads_lines(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
test_file = tmp_path / "large_file.txt"
|
||||
test_file.write_text(
|
||||
"".join(f"line {i}\n" for i in range(200)), encoding="utf-8"
|
||||
)
|
||||
tool = ReadFile(
|
||||
config_getter=lambda: ReadFileToolConfig(max_read_bytes=64),
|
||||
state=ReadFileState(),
|
||||
)
|
||||
|
||||
result = await collect_result(
|
||||
tool.run(ReadFileArgs(path=str(test_file), offset=50, limit=2))
|
||||
)
|
||||
|
||||
assert result.content == "line 50\nline 51\n"
|
||||
assert result.lines_read == 2
|
||||
assert not result.was_truncated
|
||||
|
||||
|
||||
class TestGetResultExtra:
|
||||
@pytest.mark.usefixtures("_setup_manager")
|
||||
def test_returns_none_when_no_agents_md(self, tmp_path: Path) -> None:
|
||||
|
||||
0
tests/narrator_manager/__init__.py
Normal file
0
tests/narrator_manager/__init__.py
Normal file
192
tests/narrator_manager/test_narrator_manager.py
Normal file
192
tests/narrator_manager/test_narrator_manager.py
Normal file
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from tests.stubs.fake_audio_player import FakeAudioPlayer
|
||||
from tests.stubs.fake_tts_client import FakeTTSClient
|
||||
from vibe.cli.narrator_manager import NarratorManager, NarratorState
|
||||
from vibe.cli.turn_summary import TurnSummaryResult
|
||||
from vibe.core.tts.tts_client_port import TTSResult
|
||||
|
||||
|
||||
def _make_manager(
|
||||
*,
|
||||
narrator_enabled: bool = True,
|
||||
telemetry_client: MagicMock | None = None,
|
||||
tts_client: FakeTTSClient | None = None,
|
||||
) -> tuple[NarratorManager, FakeAudioPlayer]:
|
||||
config = build_test_vibe_config(narrator_enabled=narrator_enabled)
|
||||
audio_player = FakeAudioPlayer()
|
||||
manager = NarratorManager(
|
||||
config_getter=lambda: config,
|
||||
audio_player=audio_player,
|
||||
telemetry_client=telemetry_client,
|
||||
)
|
||||
manager._tts_client = tts_client or FakeTTSClient(
|
||||
result=TTSResult(audio_data=b"fake-audio")
|
||||
)
|
||||
return manager, audio_player
|
||||
|
||||
|
||||
def _find_telemetry_calls(mock: MagicMock, event_name: str) -> list[dict[str, object]]:
|
||||
results: list[dict[str, object]] = []
|
||||
for call in mock.send_telemetry_event.call_args_list:
|
||||
if call[0][0] == event_name:
|
||||
results.append(call[0][1])
|
||||
return results
|
||||
|
||||
|
||||
class TestTelemetryTracking:
|
||||
@pytest.mark.asyncio
|
||||
async def test_requested_event_on_turn_end(self) -> None:
|
||||
mock_telemetry = MagicMock()
|
||||
manager, _ = _make_manager(telemetry_client=mock_telemetry)
|
||||
manager._turn_summary.start_turn("test")
|
||||
manager.on_turn_end()
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.requested")
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["trigger"] == "autoplay_next_message"
|
||||
assert isinstance(calls[0]["read_aloud_session_id"], str)
|
||||
assert len(calls[0]["read_aloud_session_id"]) == 36
|
||||
|
||||
def test_no_requested_event_when_narrator_disabled(self) -> None:
|
||||
mock_telemetry = MagicMock()
|
||||
manager, _ = _make_manager(
|
||||
narrator_enabled=False, telemetry_client=mock_telemetry
|
||||
)
|
||||
manager.on_turn_end()
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.requested")
|
||||
assert len(calls) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_play_started_on_speak(self) -> None:
|
||||
mock_telemetry = MagicMock()
|
||||
manager, _ = _make_manager(telemetry_client=mock_telemetry)
|
||||
|
||||
manager._turn_summary.start_turn("test")
|
||||
manager.on_turn_end()
|
||||
manager._on_turn_summary(
|
||||
TurnSummaryResult(
|
||||
summary="Test summary", generation=manager._turn_summary.generation
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.play_started")
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["speed_selection"] is None
|
||||
assert isinstance(calls[0]["time_to_first_read_s"], float)
|
||||
assert calls[0]["time_to_first_read_s"] >= 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ended_completed_on_playback_finished(self) -> None:
|
||||
mock_telemetry = MagicMock()
|
||||
manager, _ = _make_manager(telemetry_client=mock_telemetry)
|
||||
|
||||
manager._turn_summary.start_turn("test")
|
||||
manager.on_turn_end()
|
||||
manager._on_turn_summary(
|
||||
TurnSummaryResult(
|
||||
summary="Test summary", generation=manager._turn_summary.generation
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
assert manager.state == NarratorState.SPEAKING
|
||||
|
||||
manager._on_playback_finished()
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["status"] == "completed"
|
||||
assert calls[0]["error_type"] is None
|
||||
assert calls[0]["speed_selection"] is None
|
||||
assert isinstance(calls[0]["elapsed_seconds"], float)
|
||||
assert calls[0]["elapsed_seconds"] >= 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ended_error_on_tts_failure(self) -> None:
|
||||
mock_telemetry = MagicMock()
|
||||
|
||||
class FailingTTSClient:
|
||||
def __init__(self, *_args: object, **_kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
async def speak(self, text: str) -> TTSResult:
|
||||
raise RuntimeError("TTS failed")
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
manager, _ = _make_manager(telemetry_client=mock_telemetry)
|
||||
manager._tts_client = FailingTTSClient()
|
||||
|
||||
manager._turn_summary.start_turn("test")
|
||||
manager.on_turn_end()
|
||||
manager._on_turn_summary(
|
||||
TurnSummaryResult(
|
||||
summary="Test summary", generation=manager._turn_summary.generation
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["status"] == "error"
|
||||
assert calls[0]["error_type"] == "RuntimeError"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ended_canceled_on_cancel(self) -> None:
|
||||
mock_telemetry = MagicMock()
|
||||
manager, _ = _make_manager(telemetry_client=mock_telemetry)
|
||||
|
||||
manager._turn_summary.start_turn("test")
|
||||
manager.on_turn_end()
|
||||
assert manager.state == NarratorState.SUMMARIZING
|
||||
|
||||
manager.cancel()
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["status"] == "canceled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_during_speaking_fires_single_ended_event(self) -> None:
|
||||
mock_telemetry = MagicMock()
|
||||
manager, _ = _make_manager(telemetry_client=mock_telemetry)
|
||||
|
||||
manager._turn_summary.start_turn("test")
|
||||
manager.on_turn_end()
|
||||
manager._on_turn_summary(
|
||||
TurnSummaryResult(
|
||||
summary="Test summary", generation=manager._turn_summary.generation
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
assert manager.state == NarratorState.SPEAKING
|
||||
|
||||
manager.cancel()
|
||||
# Simulate the delayed callback that would come from call_soon_threadsafe
|
||||
manager._on_playback_finished()
|
||||
|
||||
calls = _find_telemetry_calls(mock_telemetry, "vibe.read_aloud.ended")
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["status"] == "canceled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_error_without_telemetry_client(self) -> None:
|
||||
manager, _ = _make_manager()
|
||||
manager._turn_summary.start_turn("test")
|
||||
manager.on_turn_end()
|
||||
manager._on_turn_summary(
|
||||
TurnSummaryResult(
|
||||
summary="Test summary", generation=manager._turn_summary.generation
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
manager._on_playback_finished()
|
||||
61
tests/narrator_manager/test_telemetry.py
Normal file
61
tests/narrator_manager/test_telemetry.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.narrator_manager.telemetry import ReadAloudTrackingState
|
||||
|
||||
|
||||
class TestReadAloudTrackingState:
|
||||
def test_default_state(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
assert state.session_id == ""
|
||||
assert state.request_time == 0.0
|
||||
assert state.play_start_time == 0.0
|
||||
|
||||
def test_reset_generates_session_id(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
state.reset()
|
||||
assert state.session_id != ""
|
||||
assert len(state.session_id) == 36 # UUID format
|
||||
|
||||
def test_reset_generates_unique_session_ids(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
state.reset()
|
||||
first_id = state.session_id
|
||||
state.reset()
|
||||
assert state.session_id != first_id
|
||||
|
||||
def test_reset_clears_play_start_time(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
state.reset()
|
||||
state.mark_play_started()
|
||||
assert state.play_start_time > 0.0
|
||||
state.reset()
|
||||
assert state.play_start_time == 0.0
|
||||
|
||||
def test_mark_play_started(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
state.reset()
|
||||
state.mark_play_started()
|
||||
assert state.play_start_time > 0.0
|
||||
|
||||
def test_time_to_first_read_s(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
state.reset()
|
||||
state.mark_play_started()
|
||||
ttfr = state.time_to_first_read_s()
|
||||
assert ttfr >= 0.0
|
||||
|
||||
def test_time_to_first_read_s_before_play(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
state.reset()
|
||||
assert state.time_to_first_read_s() == 0.0
|
||||
|
||||
def test_elapsed_since_play_s(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
state.reset()
|
||||
state.mark_play_started()
|
||||
elapsed = state.elapsed_since_play_s()
|
||||
assert elapsed >= 0.0
|
||||
|
||||
def test_elapsed_since_play_s_before_play(self) -> None:
|
||||
state = ReadAloudTrackingState()
|
||||
assert state.elapsed_since_play_s() == 0.0
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import override
|
||||
|
||||
@@ -25,7 +24,7 @@ def _exit_raiser(code: int = 0) -> None:
|
||||
|
||||
|
||||
def test_exits_on_cancel(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setattr(sys, "exit", _exit_raiser)
|
||||
|
||||
@@ -38,7 +37,7 @@ def test_exits_on_cancel(
|
||||
|
||||
|
||||
def test_warns_on_save_error(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setattr(sys, "exit", _exit_raiser)
|
||||
|
||||
@@ -49,8 +48,25 @@ def test_warns_on_save_error(
|
||||
assert "disk full" in out
|
||||
|
||||
|
||||
def test_exits_on_invalid_api_key_env_var(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setattr(sys, "exit", _exit_raiser)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
onboarding.run_onboarding(StubApp("env_var_error:BAD=NAME"))
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "Could not save the API key because this provider is configured" in out
|
||||
assert "invalid" in out
|
||||
assert "environment variable name: BAD=NAME" in out
|
||||
assert "was not saved for this session" in out
|
||||
assert "set for this session only" not in out
|
||||
|
||||
|
||||
def test_successfully_completes(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setattr(sys, "exit", _exit_raiser)
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from textual.pilot import Pilot
|
||||
from textual.widgets import Input
|
||||
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE
|
||||
from vibe.setup.onboarding import OnboardingApp
|
||||
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
|
||||
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen, persist_api_key
|
||||
|
||||
|
||||
async def _wait_for(
|
||||
@@ -55,3 +57,64 @@ async def test_ui_gets_through_the_onboarding_successfully() -> None:
|
||||
env_contents = GLOBAL_ENV_FILE.path.read_text(encoding="utf-8")
|
||||
assert "MISTRAL_API_KEY" in env_contents
|
||||
assert api_key_value in env_contents
|
||||
|
||||
|
||||
def test_api_key_screen_falls_back_to_mistral_for_provider_without_env_key() -> None:
|
||||
screen = ApiKeyScreen(
|
||||
provider=ProviderConfig(
|
||||
name="llamacpp", api_base="http://127.0.0.1:8080/v1", api_key_env_var=""
|
||||
)
|
||||
)
|
||||
|
||||
assert screen.provider.name == "mistral"
|
||||
assert screen.provider.api_key_env_var == "MISTRAL_API_KEY"
|
||||
|
||||
|
||||
def test_api_key_screen_keeps_provider_with_explicit_env_key() -> None:
|
||||
provider = ProviderConfig(
|
||||
name="custom",
|
||||
api_base="https://custom.example/v1",
|
||||
api_key_env_var="CUSTOM_API_KEY",
|
||||
)
|
||||
|
||||
screen = ApiKeyScreen(provider=provider)
|
||||
|
||||
assert screen.provider == provider
|
||||
|
||||
|
||||
def test_api_key_screen_uses_mistral_fallback_for_context_without_env_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"vibe.setup.onboarding.screens.api_key.OnboardingContext.load",
|
||||
lambda: SimpleNamespace(
|
||||
provider=ProviderConfig(
|
||||
name="llamacpp", api_base="http://127.0.0.1:8080/v1", api_key_env_var=""
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
screen = ApiKeyScreen()
|
||||
|
||||
assert screen.provider.name == "mistral"
|
||||
assert screen.provider.api_key_env_var == "MISTRAL_API_KEY"
|
||||
|
||||
|
||||
def test_persist_api_key_returns_save_error_for_invalid_env_var_name() -> None:
|
||||
provider = ProviderConfig(
|
||||
name="custom", api_base="https://custom.example/v1", api_key_env_var="BAD=NAME"
|
||||
)
|
||||
|
||||
result = persist_api_key(provider, "secret")
|
||||
|
||||
assert result == "env_var_error:BAD=NAME"
|
||||
|
||||
|
||||
def test_persist_api_key_returns_env_var_error_for_empty_env_var_name() -> None:
|
||||
provider = ProviderConfig(
|
||||
name="custom", api_base="https://custom.example/v1", api_key_env_var=""
|
||||
)
|
||||
|
||||
result = persist_api_key(provider, "secret")
|
||||
|
||||
assert result == "env_var_error:<empty>"
|
||||
|
||||
@@ -9,7 +9,8 @@ import pytest
|
||||
|
||||
from vibe.core.config import SessionLoggingConfig
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.types import LLMMessage, Role, ToolCall
|
||||
from vibe.core.types import LLMMessage, Role, SessionMetadata, ToolCall
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -1008,7 +1009,7 @@ class TestSessionLoaderUTF8Encoding:
|
||||
session_folder = session_dir / "test_20230101_120000_latin100"
|
||||
session_folder.mkdir()
|
||||
|
||||
# \x81 invalid UTF-8 and undefined in CP1252 → U+FFFD on all platforms
|
||||
# Path contains U+0081; file written as Latin-1. Decoding matches read_safe.
|
||||
metadata_content = {
|
||||
"session_id": "latin1-test",
|
||||
"start_time": "2023-01-01T12:00:00Z",
|
||||
@@ -1026,9 +1027,10 @@ class TestSessionLoaderUTF8Encoding:
|
||||
messages_file = session_folder / "messages.jsonl"
|
||||
messages_file.write_text('{"role": "user", "content": "Hello"}\n')
|
||||
|
||||
expected = SessionMetadata.model_validate_json(read_safe(metadata_file).text)
|
||||
metadata = SessionLoader.load_metadata(session_folder)
|
||||
assert metadata.session_id == "latin1-test"
|
||||
assert metadata.environment["working_directory"] == "/home/user/caf<61>_project"
|
||||
assert metadata == expected
|
||||
|
||||
def test_load_session_with_utf8_metadata_and_messages(
|
||||
self, session_config: SessionLoggingConfig
|
||||
|
||||
@@ -78,9 +78,9 @@
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
<rect fill="#121212" x="0" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="1.5" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="573.4" y="1.5" width="402.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<rect fill="#121212" x="0" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="12.2" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="24.4" y="1.5" width="805.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="829.6" y="1.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#121212" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">✓</text><text class="terminal-r3" x="24.4" y="20" textLength="549" clip-path="url(#terminal-line-0)">Teleported to Nuage: https://chat.example.com</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
<text class="terminal-r1" x="0" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">✓</text><text class="terminal-r3" x="24.4" y="20" textLength="805.2" clip-path="url(#terminal-line-0)">Teleported to a new async coding session: https://chat.example.com</text><text class="terminal-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
|
||||
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
@@ -180,9 +180,9 @@
|
||||
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="483.6" textLength="183" clip-path="url(#terminal-line-19)">devstral-latest</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="414.8" clip-path="url(#terminal-line-20)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">Type </text><text class="terminal-r3" x="231.8" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">/help</text><text class="terminal-r1" x="292.8" y="532.4" textLength="256.2" clip-path="url(#terminal-line-21)"> for more information</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="483.6" textLength="134.2" clip-path="url(#terminal-line-19)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="483.6" textLength="146.4" clip-path="url(#terminal-line-19)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="483.6" textLength="122" clip-path="url(#terminal-line-19)"> v0.0.0 · </text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="508" textLength="61" clip-path="url(#terminal-line-20)">Type </text><text class="terminal-r3" x="231.8" y="508" textLength="61" clip-path="url(#terminal-line-20)">/help</text><text class="terminal-r1" x="292.8" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> for more information</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">┃</text><text class="terminal-r5" x="24.4" y="581.2" textLength="122" clip-path="url(#terminal-line-23)">What's New</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">┃</text><text class="terminal-r1" x="24.4" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">• Feature 1</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
138
tests/test_deferred_init.py
Normal file
138
tests/test_deferred_init.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Tests for deferred initialization: _complete_init, wait_for_init, integrate_mcp idempotency."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_agent_loop, build_test_vibe_config
|
||||
from tests.stubs.fake_mcp_registry import FakeMCPRegistry
|
||||
from vibe.core.config import MCPStdio
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _complete_init
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCompleteInit:
|
||||
def test_success_sets_init_complete(self) -> None:
|
||||
loop = build_test_agent_loop(defer_heavy_init=True)
|
||||
assert not loop.is_initialized
|
||||
|
||||
loop._complete_init()
|
||||
|
||||
assert loop.is_initialized
|
||||
assert loop._init_error is None
|
||||
|
||||
def test_failure_sets_init_complete_and_stores_error(self) -> None:
|
||||
loop = build_test_agent_loop(defer_heavy_init=True)
|
||||
error = RuntimeError("mcp boom")
|
||||
|
||||
with patch.object(loop.tool_manager, "integrate_mcp", side_effect=error):
|
||||
loop._complete_init()
|
||||
|
||||
assert loop.is_initialized
|
||||
assert loop._init_error is error
|
||||
|
||||
def test_mcp_integration_internal_failure_sets_init_error(self) -> None:
|
||||
mcp_server = MCPStdio(name="test-server", transport="stdio", command="echo")
|
||||
config = build_test_vibe_config(mcp_servers=[mcp_server])
|
||||
loop = build_test_agent_loop(config=config, defer_heavy_init=True)
|
||||
|
||||
with patch.object(
|
||||
loop.tool_manager._mcp_registry,
|
||||
"get_tools",
|
||||
side_effect=RuntimeError("mcp discovery boom"),
|
||||
):
|
||||
loop._complete_init()
|
||||
|
||||
assert loop.is_initialized
|
||||
assert isinstance(loop._init_error, RuntimeError)
|
||||
assert str(loop._init_error) == "mcp discovery boom"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wait_for_init
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWaitForInit:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_immediately_when_already_complete(self) -> None:
|
||||
loop = build_test_agent_loop(defer_heavy_init=True)
|
||||
loop._complete_init()
|
||||
|
||||
await loop.wait_for_init() # should not block
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waits_for_background_thread(self) -> None:
|
||||
loop = build_test_agent_loop(defer_heavy_init=True)
|
||||
|
||||
thread = threading.Thread(target=loop._complete_init, daemon=True)
|
||||
thread.start()
|
||||
|
||||
await loop.wait_for_init()
|
||||
thread.join(timeout=1)
|
||||
|
||||
assert loop.is_initialized
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_stored_error(self) -> None:
|
||||
loop = build_test_agent_loop(defer_heavy_init=True)
|
||||
error = RuntimeError("init failed")
|
||||
|
||||
with patch.object(loop.tool_manager, "integrate_mcp", side_effect=error):
|
||||
loop._complete_init()
|
||||
|
||||
with pytest.raises(RuntimeError, match="init failed"):
|
||||
await loop.wait_for_init()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_error_for_every_caller(self) -> None:
|
||||
loop = build_test_agent_loop(defer_heavy_init=True)
|
||||
error = RuntimeError("once only")
|
||||
|
||||
with patch.object(loop.tool_manager, "integrate_mcp", side_effect=error):
|
||||
loop._complete_init()
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await loop.wait_for_init()
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await loop.wait_for_init()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# integrate_mcp idempotency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIntegrateMcpIdempotency:
|
||||
def test_second_call_is_noop(self) -> None:
|
||||
mcp_server = MCPStdio(name="test-server", transport="stdio", command="echo")
|
||||
config = build_test_vibe_config(mcp_servers=[mcp_server])
|
||||
registry = FakeMCPRegistry()
|
||||
manager = ToolManager(lambda: config, mcp_registry=registry, defer_mcp=True)
|
||||
|
||||
manager.integrate_mcp()
|
||||
tools_after_first = dict(manager.registered_tools)
|
||||
|
||||
# Spy on the registry to ensure get_tools is not called again.
|
||||
registry.get_tools = MagicMock(wraps=registry.get_tools)
|
||||
manager.integrate_mcp()
|
||||
|
||||
registry.get_tools.assert_not_called()
|
||||
assert manager.registered_tools == tools_after_first
|
||||
|
||||
def test_flag_not_set_when_no_servers(self) -> None:
|
||||
config = build_test_vibe_config(mcp_servers=[])
|
||||
manager = ToolManager(lambda: config, defer_mcp=True)
|
||||
|
||||
manager.integrate_mcp()
|
||||
|
||||
# No servers means the method returns early without setting the flag,
|
||||
# so a future call with servers would still run discovery.
|
||||
assert not manager._mcp_integrated
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from vibe.core.config import TTSModelConfig, TTSProviderConfig
|
||||
@@ -27,13 +27,20 @@ def _make_model() -> TTSModelConfig:
|
||||
|
||||
|
||||
class TestMistralTTSClientInit:
|
||||
def test_client_configured_with_base_url_and_auth(
|
||||
def test_lazy_client_creation(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
client = MistralTTSClient(_make_provider(), _make_model())
|
||||
assert client._client is None
|
||||
|
||||
def test_get_client_creates_mistral_instance(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
client = MistralTTSClient(_make_provider(), _make_model())
|
||||
assert str(client._client.base_url) == "https://api.mistral.ai/v1/"
|
||||
assert client._client.headers["authorization"] == "Bearer test-key"
|
||||
sdk_client = client._get_client()
|
||||
assert sdk_client is not None
|
||||
assert client._client is sdk_client
|
||||
assert client._get_client() is sdk_client
|
||||
|
||||
|
||||
class TestMistralTTSClient:
|
||||
@@ -46,55 +53,62 @@ class TestMistralTTSClient:
|
||||
raw_audio = b"fake-audio-data-for-testing"
|
||||
encoded_audio = base64.b64encode(raw_audio).decode()
|
||||
|
||||
async def mock_post(self_client, url, **kwargs):
|
||||
assert url == "/audio/speech"
|
||||
body = kwargs["json"]
|
||||
assert body["model"] == "voxtral-mini-tts-latest"
|
||||
assert body["input"] == "Hello"
|
||||
assert body["voice_id"] == "gb_jane_neutral"
|
||||
assert body["stream"] is False
|
||||
assert body["response_format"] == "wav"
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
json={"audio_data": encoded_audio},
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
mock_response = MagicMock()
|
||||
mock_response.audio_data = encoded_audio
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
|
||||
mock_complete_async = AsyncMock(return_value=mock_response)
|
||||
|
||||
client = MistralTTSClient(_make_provider(), _make_model())
|
||||
result = await client.speak("Hello")
|
||||
|
||||
with patch.object(
|
||||
type(client._get_client().audio.speech),
|
||||
"complete_async",
|
||||
mock_complete_async,
|
||||
):
|
||||
result = await client.speak("Hello")
|
||||
|
||||
assert isinstance(result, TTSResult)
|
||||
assert result.audio_data == raw_audio
|
||||
await client.close()
|
||||
mock_complete_async.assert_called_once_with(
|
||||
model="voxtral-mini-tts-latest",
|
||||
input="Hello",
|
||||
voice_id="gb_jane_neutral",
|
||||
response_format="wav",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_speak_raises_on_http_error(
|
||||
async def test_speak_raises_on_sdk_error(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
import httpx
|
||||
from mistralai.client.errors import SDKError
|
||||
|
||||
async def mock_post(self_client, url, **kwargs):
|
||||
return httpx.Response(
|
||||
status_code=500,
|
||||
json={"error": "Internal Server Error"},
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
|
||||
fake_response = httpx.Response(
|
||||
status_code=500,
|
||||
request=httpx.Request("POST", "https://api.mistral.ai/v1/audio/speech"),
|
||||
)
|
||||
mock_complete_async = AsyncMock(
|
||||
side_effect=SDKError("API error", fake_response)
|
||||
)
|
||||
|
||||
client = MistralTTSClient(_make_provider(), _make_model())
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
type(client._get_client().audio.speech),
|
||||
"complete_async",
|
||||
mock_complete_async,
|
||||
),
|
||||
pytest.raises(SDKError),
|
||||
):
|
||||
await client.speak("Hello")
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_closes_underlying_client(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def test_close_resets_client(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
|
||||
client = MistralTTSClient(_make_provider(), _make_model())
|
||||
client._get_client()
|
||||
assert client._client is not None
|
||||
await client.close()
|
||||
assert client._client.is_closed
|
||||
assert client._client is None
|
||||
|
||||
@@ -122,7 +122,7 @@ def test_load_whats_new_content_handles_os_error(tmp_path: Path) -> None:
|
||||
whats_new_file.write_text("content")
|
||||
|
||||
with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
|
||||
with patch.object(Path, "read_text", side_effect=OSError("Permission denied")):
|
||||
with patch.object(Path, "read_bytes", side_effect=OSError("Permission denied")):
|
||||
result = load_whats_new_content()
|
||||
|
||||
assert result is None
|
||||
|
||||
37
uv.lock
generated
37
uv.lock
generated
@@ -3,7 +3,7 @@ revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-04-02T14:58:56.644057Z"
|
||||
exclude-newer = "2026-04-06T21:12:11.186502Z"
|
||||
exclude-newer-span = "P7D"
|
||||
|
||||
[options.exclude-newer-package]
|
||||
@@ -569,6 +569,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonpatch"
|
||||
version = "1.33"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpointer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonpath-python"
|
||||
version = "1.1.5"
|
||||
@@ -578,6 +590,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/50/1a313fb700526b134c71eb8a225d8b83be0385dbb0204337b4379c698cef/jsonpath_python-1.1.5-py3-none-any.whl", hash = "sha256:a60315404d70a65e76c9a782c84e50600480221d94a58af47b7b4d437351cb4b", size = 14090, upload-time = "2026-03-17T06:16:39.152Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonpointer"
|
||||
version = "3.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
@@ -787,17 +808,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mistral-vibe"
|
||||
version = "2.7.4"
|
||||
version = "2.7.5"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "agent-client-protocol" },
|
||||
{ name = "anyio" },
|
||||
{ name = "cachetools" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "gitpython" },
|
||||
{ name = "giturlparse" },
|
||||
{ name = "google-auth" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jsonpatch" },
|
||||
{ name = "keyring" },
|
||||
{ name = "markdownify" },
|
||||
{ name = "mcp" },
|
||||
@@ -853,11 +876,13 @@ requires-dist = [
|
||||
{ name = "agent-client-protocol", specifier = "==0.9.0" },
|
||||
{ name = "anyio", specifier = ">=4.12.0" },
|
||||
{ name = "cachetools", specifier = ">=5.5.0" },
|
||||
{ name = "charset-normalizer", specifier = ">=3.4.4" },
|
||||
{ name = "cryptography", specifier = ">=44.0.0,<=46.0.3" },
|
||||
{ name = "gitpython", specifier = ">=3.1.46" },
|
||||
{ name = "giturlparse", specifier = ">=0.14.0" },
|
||||
{ name = "google-auth", specifier = ">=2.0.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "jsonpatch", specifier = ">=1.33" },
|
||||
{ name = "keyring", specifier = ">=25.6.0" },
|
||||
{ name = "markdownify", specifier = ">=1.2.2" },
|
||||
{ name = "mcp", specifier = ">=1.14.0" },
|
||||
@@ -876,7 +901,7 @@ requires-dist = [
|
||||
{ name = "requests", specifier = ">=2.20.0" },
|
||||
{ name = "rich", specifier = ">=14.0.0" },
|
||||
{ name = "sounddevice", specifier = ">=0.5.1" },
|
||||
{ name = "textual", specifier = ">=8.1.1" },
|
||||
{ name = "textual", specifier = ">=8.2.1" },
|
||||
{ name = "textual-speedups", specifier = ">=0.2.1" },
|
||||
{ name = "tomli-w", specifier = ">=1.2.0" },
|
||||
{ name = "tree-sitter", specifier = ">=0.25.2" },
|
||||
@@ -1901,7 +1926,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "textual"
|
||||
version = "8.1.1"
|
||||
version = "8.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py", extra = ["linkify"] },
|
||||
@@ -1911,9 +1936,9 @@ dependencies = [
|
||||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/23/8c709655c5f2208ee82ab81b8104802421865535c278a7649b842b129db1/textual-8.1.1.tar.gz", hash = "sha256:eef0256a6131f06a20ad7576412138c1f30f92ddeedd055953c08d97044bc317", size = 1843002, upload-time = "2026-03-10T10:01:38.493Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/07/766ad19cf2b15cae2d79e0db46a1b783b62316e9ff3e058e7424b2a4398b/textual-8.2.1.tar.gz", hash = "sha256:4176890e9cd5c95dcdd206541b2956b0808e74c8c36381c88db53dcb45237451", size = 1848386, upload-time = "2026-03-29T03:57:32.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/50/21/421b02bf5943172b7a9320712a5e0d74a02a8f7597284e3f8b5b06c70b8d/textual-8.1.1-py3-none-any.whl", hash = "sha256:6712f96e335cd782e76193dee16b9c8875fe0699d923bc8d3f1228fd23e773a6", size = 719598, upload-time = "2026-03-10T10:01:48.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/09/c6f000c2e3702036e593803319af02feee58a662528d0d5728a37e1cf81b/textual-8.2.1-py3-none-any.whl", hash = "sha256:746cbf947a8ca875afc09779ef38cadbc7b9f15ac886a5090f7099fef5ade990", size = 723871, upload-time = "2026-03-29T03:57:34.334Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -3,4 +3,4 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.7.4"
|
||||
__version__ = "2.7.5"
|
||||
|
||||
@@ -19,10 +19,12 @@ from vibe.core.tools.builtins.search_replace import (
|
||||
SearchReplaceResult,
|
||||
)
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
from vibe.core.utils.io import ReadSafeResult
|
||||
|
||||
|
||||
class AcpSearchReplaceState(BaseToolState, AcpToolState):
|
||||
file_backup_content: str | None = None
|
||||
file_backup_encoding: str = "utf-8"
|
||||
|
||||
|
||||
class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
||||
@@ -35,7 +37,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
||||
def _get_tool_state_class(cls) -> type[AcpSearchReplaceState]:
|
||||
return AcpSearchReplaceState
|
||||
|
||||
async def _read_file(self, file_path: Path) -> str:
|
||||
async def _read_file(self, file_path: Path) -> ReadSafeResult:
|
||||
client, session_id, _ = self._load_state()
|
||||
|
||||
await self._send_in_progress_session_update()
|
||||
@@ -48,7 +50,8 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
||||
raise ToolError(f"Unexpected error reading {file_path}: {e}") from e
|
||||
|
||||
self.state.file_backup_content = response.content
|
||||
return response.content
|
||||
self.state.file_backup_encoding = "utf-8"
|
||||
return ReadSafeResult(response.content, "utf-8")
|
||||
|
||||
async def _backup_file(self, file_path: Path) -> None:
|
||||
if self.state.file_backup_content is None:
|
||||
@@ -57,9 +60,10 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
||||
await self._write_file(
|
||||
file_path.with_suffix(file_path.suffix + ".bak"),
|
||||
self.state.file_backup_content,
|
||||
self.state.file_backup_encoding,
|
||||
)
|
||||
|
||||
async def _write_file(self, file_path: Path, content: str) -> None:
|
||||
async def _write_file(self, file_path: Path, content: str, encoding: str) -> None:
|
||||
client, session_id, _ = self._load_state()
|
||||
|
||||
try:
|
||||
|
||||
@@ -207,11 +207,14 @@ def run_cli(args: argparse.Namespace) -> None:
|
||||
client_name="vibe_cli",
|
||||
client_version=__version__,
|
||||
),
|
||||
defer_heavy_init=True,
|
||||
)
|
||||
|
||||
if loaded_session:
|
||||
_resume_previous_session(agent_loop, *loaded_session)
|
||||
|
||||
agent_loop.start_deferred_init()
|
||||
|
||||
run_textual_ui(
|
||||
agent_loop=agent_loop,
|
||||
startup=StartupOptions(
|
||||
|
||||
@@ -10,7 +10,7 @@ from rich import print as rprint
|
||||
from vibe import __version__
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config.harness_files import init_harness_files_manager
|
||||
from vibe.core.trusted_folders import has_trustable_content, trusted_folders_manager
|
||||
from vibe.core.trusted_folders import find_trustable_files, trusted_folders_manager
|
||||
from vibe.setup.trusted_folders.trust_folder_dialog import (
|
||||
TrustDialogQuitException,
|
||||
ask_trust_folder,
|
||||
@@ -118,7 +118,12 @@ def check_and_resolve_trusted_folder() -> None:
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if not has_trustable_content(cwd) or cwd.resolve() == Path.home().resolve():
|
||||
if cwd.resolve() == Path.home().resolve():
|
||||
return
|
||||
|
||||
detected_files = find_trustable_files(cwd)
|
||||
|
||||
if not detected_files:
|
||||
return
|
||||
|
||||
is_folder_trusted = trusted_folders_manager.is_trusted(cwd)
|
||||
@@ -127,7 +132,7 @@ def check_and_resolve_trusted_folder() -> None:
|
||||
return
|
||||
|
||||
try:
|
||||
is_folder_trusted = ask_trust_folder(cwd)
|
||||
is_folder_trusted = ask_trust_folder(cwd, detected_files)
|
||||
except (KeyboardInterrupt, EOFError, TrustDialogQuitException):
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
class HistoryManager:
|
||||
def __init__(self, history_file: Path, max_entries: int = 100) -> None:
|
||||
@@ -18,20 +20,22 @@ class HistoryManager:
|
||||
return
|
||||
|
||||
try:
|
||||
with self.history_file.open("r", encoding="utf-8") as f:
|
||||
entries = []
|
||||
for raw_line in f:
|
||||
raw_line = raw_line.rstrip("\n\r")
|
||||
if not raw_line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(raw_line)
|
||||
except json.JSONDecodeError:
|
||||
entry = raw_line
|
||||
entries.append(entry if isinstance(entry, str) else str(entry))
|
||||
self._entries = entries[-self.max_entries :]
|
||||
except (OSError, UnicodeDecodeError):
|
||||
text = read_safe(self.history_file).text
|
||||
except OSError:
|
||||
self._entries = []
|
||||
return
|
||||
|
||||
entries = []
|
||||
for raw_line in text.splitlines():
|
||||
raw_line = raw_line.rstrip("\n\r")
|
||||
if not raw_line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(raw_line)
|
||||
except json.JSONDecodeError:
|
||||
entry = raw_line
|
||||
entries.append(entry if isinstance(entry, str) else str(entry))
|
||||
self._entries = entries[-self.max_entries :]
|
||||
|
||||
def _save_history(self) -> None:
|
||||
try:
|
||||
|
||||
@@ -7,6 +7,7 @@ from vibe.cli.narrator_manager.narrator_manager_port import (
|
||||
NarratorManagerListener,
|
||||
NarratorState,
|
||||
)
|
||||
from vibe.cli.narrator_manager.telemetry import ReadAloudTrackingState
|
||||
from vibe.cli.turn_summary import (
|
||||
NoopTurnSummary,
|
||||
TurnSummaryResult,
|
||||
@@ -24,16 +25,21 @@ if TYPE_CHECKING:
|
||||
from vibe.cli.turn_summary import TurnSummaryPort
|
||||
from vibe.core.audio_player.audio_player_port import AudioPlayerPort
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.telemetry.send import TelemetryClient
|
||||
from vibe.core.tts.tts_client_port import TTSClientPort
|
||||
from vibe.core.types import BaseEvent
|
||||
|
||||
|
||||
class NarratorManager:
|
||||
def __init__(
|
||||
self, config_getter: Callable[[], VibeConfig], audio_player: AudioPlayerPort
|
||||
self,
|
||||
config_getter: Callable[[], VibeConfig],
|
||||
audio_player: AudioPlayerPort,
|
||||
telemetry_client: TelemetryClient | None = None,
|
||||
) -> None:
|
||||
self._config_getter = config_getter
|
||||
self._audio_player = audio_player
|
||||
self._telemetry_client = telemetry_client
|
||||
config = config_getter()
|
||||
self._turn_summary: TurnSummaryPort = self._make_turn_summary(config)
|
||||
self._turn_summary.on_summary = self._on_turn_summary
|
||||
@@ -43,6 +49,7 @@ class NarratorManager:
|
||||
self._cancel_summary: Callable[[], bool] | None = None
|
||||
self._close_tasks: set[asyncio.Task[Any]] = set()
|
||||
self._listeners: list[NarratorManagerListener] = []
|
||||
self._tracking = ReadAloudTrackingState()
|
||||
|
||||
@property
|
||||
def state(self) -> NarratorState:
|
||||
@@ -99,8 +106,12 @@ class NarratorManager:
|
||||
):
|
||||
self._cancel_summary = cancel_summary
|
||||
self._set_state(NarratorState.SUMMARIZING)
|
||||
self._tracking.reset()
|
||||
self._on_read_aloud_requested()
|
||||
|
||||
def cancel(self) -> None:
|
||||
if self._state != NarratorState.IDLE:
|
||||
self._on_read_aloud_ended("canceled")
|
||||
if self._cancel_summary is not None:
|
||||
self._cancel_summary()
|
||||
self._cancel_summary = None
|
||||
@@ -178,17 +189,65 @@ class NarratorManager:
|
||||
loop = asyncio.get_running_loop()
|
||||
tts_result = await self._tts_client.speak(text)
|
||||
self._set_state(NarratorState.SPEAKING)
|
||||
self._tracking.mark_play_started()
|
||||
self._on_read_aloud_play_started()
|
||||
self._audio_player.play(
|
||||
tts_result.audio_data,
|
||||
AudioFormat.WAV,
|
||||
on_finished=lambda: loop.call_soon_threadsafe(
|
||||
self._set_state, NarratorState.IDLE
|
||||
self._on_playback_finished
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.warning("TTS speak failed", exc_info=True)
|
||||
self._on_read_aloud_ended("error", error_type=type(exc).__name__)
|
||||
self._set_state(NarratorState.IDLE)
|
||||
|
||||
def _on_playback_finished(self) -> None:
|
||||
if self._state != NarratorState.SPEAKING:
|
||||
return
|
||||
self._on_read_aloud_ended("completed")
|
||||
self._set_state(NarratorState.IDLE)
|
||||
|
||||
def _on_read_aloud_requested(self) -> None:
|
||||
if not self._telemetry_client:
|
||||
return
|
||||
self._telemetry_client.send_telemetry_event(
|
||||
"vibe.read_aloud.requested",
|
||||
{
|
||||
"read_aloud_session_id": self._tracking.session_id,
|
||||
"trigger": "autoplay_next_message",
|
||||
},
|
||||
)
|
||||
|
||||
def _on_read_aloud_play_started(self) -> None:
|
||||
if not self._telemetry_client:
|
||||
return
|
||||
self._telemetry_client.send_telemetry_event(
|
||||
"vibe.read_aloud.play_started",
|
||||
{
|
||||
"read_aloud_session_id": self._tracking.session_id,
|
||||
"time_to_first_read_s": self._tracking.time_to_first_read_s(),
|
||||
"speed_selection": None,
|
||||
},
|
||||
)
|
||||
|
||||
def _on_read_aloud_ended(
|
||||
self, status: str, *, error_type: str | None = None
|
||||
) -> None:
|
||||
if not self._telemetry_client:
|
||||
return
|
||||
self._telemetry_client.send_telemetry_event(
|
||||
"vibe.read_aloud.ended",
|
||||
{
|
||||
"read_aloud_session_id": self._tracking.session_id,
|
||||
"status": status,
|
||||
"error_type": error_type,
|
||||
"speed_selection": None,
|
||||
"elapsed_seconds": self._tracking.elapsed_since_play_s(),
|
||||
},
|
||||
)
|
||||
|
||||
def _set_state(self, state: NarratorState) -> None:
|
||||
self._state = state
|
||||
for listener in list(self._listeners):
|
||||
|
||||
30
vibe/cli/narrator_manager/telemetry.py
Normal file
30
vibe/cli/narrator_manager/telemetry.py
Normal 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
|
||||
@@ -71,10 +71,18 @@ def stop_and_print() -> None:
|
||||
|
||||
output_path = Path(f"{_state.label}-profile.html")
|
||||
output_path.write_text(_state.profiler.output_html(), encoding="utf-8")
|
||||
|
||||
text_path = Path(f"{_state.label}-profile.txt")
|
||||
text_path.write_text(_state.profiler.output_text(color=False), encoding="utf-8")
|
||||
|
||||
print(
|
||||
f"\n[profiler:{_state.label}] Saved HTML profile to {output_path.resolve()}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
f"[profiler:{_state.label}] Saved text profile to {text_path.resolve()}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(_state.profiler.output_text(color=True), file=sys.stderr)
|
||||
|
||||
_state.profiler = None
|
||||
|
||||
@@ -191,7 +191,7 @@ def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
|
||||
|
||||
def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]:
|
||||
if keybindings_path.exists():
|
||||
content = read_safe(keybindings_path)
|
||||
content = read_safe(keybindings_path).text
|
||||
return _parse_keybindings(content)
|
||||
keybindings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return []
|
||||
|
||||
@@ -370,6 +370,7 @@ class VibeApp(App): # noqa: PLR0904
|
||||
|
||||
self._rewind_mode = False
|
||||
self._rewind_highlighted_widget: UserMessage | None = None
|
||||
self._fatal_init_error = False
|
||||
|
||||
@property
|
||||
def config(self) -> VibeConfig:
|
||||
@@ -449,6 +450,8 @@ class VibeApp(App): # noqa: PLR0904
|
||||
|
||||
self.call_after_refresh(self._refresh_banner)
|
||||
|
||||
self.run_worker(self._watch_init_completion(), exclusive=False)
|
||||
|
||||
if self._show_resume_picker:
|
||||
self.run_worker(self._show_session_picker(), exclusive=False)
|
||||
elif self._initial_prompt or self._teleport_on_start:
|
||||
@@ -457,6 +460,37 @@ class VibeApp(App): # noqa: PLR0904
|
||||
gc.collect()
|
||||
gc.freeze()
|
||||
|
||||
async def _watch_init_completion(self) -> None:
|
||||
"""Show 'Initializing' loading indicator until background init finishes."""
|
||||
init_widget = None
|
||||
try:
|
||||
if not self.agent_loop.is_initialized:
|
||||
await self._ensure_loading_widget("Initializing")
|
||||
init_widget = self._loading_widget
|
||||
await self.agent_loop.wait_for_init()
|
||||
except Exception as e:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
f"Background initialization failed: {e}",
|
||||
collapsed=self._tools_collapsed,
|
||||
)
|
||||
)
|
||||
await self._mount_and_scroll(
|
||||
Static("Press any key to exit...", classes="error-hint")
|
||||
)
|
||||
if self._chat_input_container:
|
||||
self._chat_input_container.disabled = True
|
||||
self._chat_input_container.display = False
|
||||
self._fatal_init_error = True
|
||||
finally:
|
||||
if self._loading_widget is init_widget:
|
||||
await self._remove_loading_widget()
|
||||
self._refresh_banner()
|
||||
try:
|
||||
self.query_one(MCPApp).refresh_index()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _process_initial_prompt(self) -> None:
|
||||
if self._teleport_on_start:
|
||||
self.run_worker(
|
||||
@@ -470,6 +504,10 @@ class VibeApp(App): # noqa: PLR0904
|
||||
def _is_file_watcher_enabled(self) -> bool:
|
||||
return self.config.file_watcher_for_autocomplete
|
||||
|
||||
def on_key(self) -> None:
|
||||
if self._fatal_init_error:
|
||||
self.exit()
|
||||
|
||||
async def on_chat_input_container_submitted(
|
||||
self, event: ChatInputContainer.Submitted
|
||||
) -> None:
|
||||
@@ -754,7 +792,7 @@ class VibeApp(App): # noqa: PLR0904
|
||||
self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
|
||||
|
||||
try:
|
||||
skill_content = read_safe(skill_info.skill_path)
|
||||
skill_content = read_safe(skill_info.skill_path).text
|
||||
except OSError as e:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
@@ -1059,9 +1097,17 @@ class VibeApp(App): # noqa: PLR0904
|
||||
self._agent_running = True
|
||||
|
||||
await self._remove_loading_widget()
|
||||
await self._ensure_loading_widget()
|
||||
|
||||
try:
|
||||
show_init_spinner = not self.agent_loop.is_initialized
|
||||
if show_init_spinner:
|
||||
await self._ensure_loading_widget("Initializing")
|
||||
await self.agent_loop.wait_for_init()
|
||||
if show_init_spinner:
|
||||
await self._remove_loading_widget()
|
||||
self._refresh_banner()
|
||||
|
||||
await self._ensure_loading_widget()
|
||||
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
|
||||
self._narrator_manager.cancel()
|
||||
self._narrator_manager.on_turn_start(rendered_prompt)
|
||||
@@ -1088,6 +1134,11 @@ class VibeApp(App): # noqa: PLR0904
|
||||
except Exception as e:
|
||||
await self._handle_turn_error()
|
||||
|
||||
# _watch_init_completion already rendered the fatal startup error
|
||||
# and told the user to exit -- don't duplicate the message.
|
||||
if self._fatal_init_error:
|
||||
return
|
||||
|
||||
message = str(e)
|
||||
if isinstance(e, RateLimitError):
|
||||
message = self._rate_limit_message()
|
||||
@@ -2542,7 +2593,9 @@ class VibeApp(App): # noqa: PLR0904
|
||||
|
||||
def _make_default_narrator_manager(self) -> NarratorManager:
|
||||
return NarratorManager(
|
||||
config_getter=lambda: self.config, audio_player=AudioPlayer()
|
||||
config_getter=lambda: self.config,
|
||||
audio_player=AudioPlayer(),
|
||||
telemetry_client=self.agent_loop.telemetry_client,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class ExternalEditor:
|
||||
parts = shlex.split(editor)
|
||||
subprocess.run([*parts, filepath], check=True)
|
||||
|
||||
content = read_safe(Path(filepath)).rstrip()
|
||||
content = read_safe(Path(filepath)).text.rstrip()
|
||||
return content if content != initial_content else None
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return
|
||||
|
||||
@@ -68,6 +68,8 @@ class Banner(Static):
|
||||
self.state = self._initial_state
|
||||
|
||||
def watch_state(self) -> None:
|
||||
if not self.is_mounted:
|
||||
return
|
||||
self.query_one("#banner-model", NoMarkupStatic).update(self.state.active_model)
|
||||
self.query_one("#banner-meta-counts", NoMarkupStatic).update(
|
||||
self._format_meta_counts()
|
||||
|
||||
@@ -78,6 +78,11 @@ class MCPApp(Container):
|
||||
self._refresh_view(self._viewing_server)
|
||||
self.query_one(OptionList).focus()
|
||||
|
||||
def refresh_index(self) -> None:
|
||||
"""Re-snapshot the tool index (e.g. after deferred MCP discovery)."""
|
||||
self._index = collect_mcp_tool_index(self._mcp_servers, self._tool_manager)
|
||||
self._refresh_view(self._viewing_server)
|
||||
|
||||
def on_descendant_blur(self, _event: DescendantBlur) -> None:
|
||||
self.query_one(OptionList).focus()
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class TeleportMessage(StatusMessage):
|
||||
if self._error:
|
||||
return f"Teleport failed: {self._error}"
|
||||
if self._final_url:
|
||||
return f"Teleported to Nuage: {self._final_url}"
|
||||
return f"Teleported to a new async coding session: {self._final_url}"
|
||||
return self._status
|
||||
|
||||
def set_status(self, status: str) -> None:
|
||||
|
||||
@@ -24,7 +24,7 @@ def load_whats_new_content() -> str | None:
|
||||
if not whats_new_file.exists():
|
||||
return None
|
||||
try:
|
||||
content = read_safe(whats_new_file).strip()
|
||||
content = read_safe(whats_new_file).text.strip()
|
||||
return content if content else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Callable, Generator
|
||||
import contextlib
|
||||
import copy
|
||||
from enum import StrEnum, auto
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
import threading
|
||||
from threading import Thread
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
@@ -152,6 +154,7 @@ class AgentLoop:
|
||||
def __init__(
|
||||
self,
|
||||
config: VibeConfig,
|
||||
*,
|
||||
agent_name: str = BuiltinAgentName.DEFAULT,
|
||||
message_observer: Callable[[LLMMessage], None] | None = None,
|
||||
max_turns: int | None = None,
|
||||
@@ -160,8 +163,12 @@ class AgentLoop:
|
||||
enable_streaming: bool = False,
|
||||
entrypoint_metadata: EntrypointMetadata | None = None,
|
||||
is_subagent: bool = False,
|
||||
defer_heavy_init: bool = False,
|
||||
) -> None:
|
||||
self._base_config = config
|
||||
self._init_complete = threading.Event()
|
||||
self._init_error: Exception | None = None
|
||||
|
||||
self.mcp_registry = MCPRegistry()
|
||||
self.agent_manager = AgentManager(
|
||||
lambda: self._base_config,
|
||||
@@ -169,7 +176,9 @@ class AgentLoop:
|
||||
allow_subagent=is_subagent,
|
||||
)
|
||||
self.tool_manager = ToolManager(
|
||||
lambda: self.config, mcp_registry=self.mcp_registry
|
||||
lambda: self.config,
|
||||
mcp_registry=self.mcp_registry,
|
||||
defer_mcp=defer_heavy_init,
|
||||
)
|
||||
self.skill_manager = SkillManager(lambda: self.config)
|
||||
self.message_observer = message_observer
|
||||
@@ -190,11 +199,18 @@ class AgentLoop:
|
||||
self._setup_middleware()
|
||||
|
||||
system_prompt = get_universal_system_prompt(
|
||||
self.tool_manager, self.config, self.skill_manager, self.agent_manager
|
||||
self.tool_manager,
|
||||
self.config,
|
||||
self.skill_manager,
|
||||
self.agent_manager,
|
||||
include_git_status=not defer_heavy_init,
|
||||
)
|
||||
system_message = LLMMessage(role=Role.system, content=system_prompt)
|
||||
self.messages = MessageList(initial=[system_message], observer=message_observer)
|
||||
|
||||
if not defer_heavy_init:
|
||||
self._init_complete.set()
|
||||
|
||||
self.stats = AgentStats()
|
||||
self.approval_callback: ApprovalCallback | None = None
|
||||
self.user_input_callback: UserInputCallback | None = None
|
||||
@@ -233,6 +249,55 @@ class AgentLoop:
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def start_deferred_init(self) -> threading.Thread:
|
||||
"""Spawn a daemon thread that finishes deferred heavy I/O.
|
||||
|
||||
Returns the started thread so callers can join if needed.
|
||||
"""
|
||||
thread = threading.Thread(
|
||||
target=self._complete_init, daemon=True, name="agent_loop_init"
|
||||
)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
@property
|
||||
def is_initialized(self) -> bool:
|
||||
"""Whether deferred initialization has completed (successfully or not)."""
|
||||
return self._init_complete.is_set()
|
||||
|
||||
def _complete_init(self) -> None:
|
||||
"""Run deferred heavy I/O: MCP discovery and git status.
|
||||
|
||||
Intended to be called from a background thread when
|
||||
``defer_heavy_init=True`` was passed to ``__init__``.
|
||||
"""
|
||||
try:
|
||||
self.tool_manager.integrate_mcp(raise_on_failure=True)
|
||||
system_prompt = get_universal_system_prompt(
|
||||
self.tool_manager, self.config, self.skill_manager, self.agent_manager
|
||||
)
|
||||
self.messages.update_system_prompt(system_prompt)
|
||||
except Exception as exc:
|
||||
self._init_error = exc
|
||||
finally:
|
||||
self._init_complete.set()
|
||||
|
||||
async def wait_for_init(self) -> None:
|
||||
"""Await deferred initialization from an async context.
|
||||
|
||||
If deferred init failed, all callers raise the stored error.
|
||||
|
||||
A copy of the stored exception is raised each time so that concurrent
|
||||
callers do not share (and mutate) the same ``__traceback__`` object.
|
||||
"""
|
||||
if self.is_initialized:
|
||||
if err := self._init_error:
|
||||
raise copy.copy(err).with_traceback(err.__traceback__)
|
||||
return
|
||||
await asyncio.to_thread(self._init_complete.wait)
|
||||
if err := self._init_error:
|
||||
raise copy.copy(err).with_traceback(err.__traceback__)
|
||||
|
||||
@property
|
||||
def agent_profile(self) -> AgentProfile:
|
||||
return self.agent_manager.active_profile
|
||||
@@ -272,7 +337,7 @@ class AgentLoop:
|
||||
|
||||
self.config.tools[tool_name]["permission"] = permission.value
|
||||
|
||||
def add_session_rule(self, rule: ApprovedRule) -> None:
|
||||
def _add_session_rule(self, rule: ApprovedRule) -> None:
|
||||
self._session_rules.append(rule)
|
||||
|
||||
def _is_permission_covered(self, tool_name: str, rp: RequiredPermission) -> bool:
|
||||
@@ -292,7 +357,7 @@ class AgentLoop:
|
||||
"""Handle 'Allow Always' approval: add session rules or set tool-level permission."""
|
||||
if required_permissions:
|
||||
for rp in required_permissions:
|
||||
self.add_session_rule(
|
||||
self._add_session_rule(
|
||||
ApprovedRule(
|
||||
tool_name=tool_name,
|
||||
scope=rp.scope,
|
||||
|
||||
@@ -113,7 +113,7 @@ class IgnoreRules:
|
||||
gitignore_path = root / ".gitignore"
|
||||
if gitignore_path.exists():
|
||||
try:
|
||||
text = read_safe(gitignore_path)
|
||||
text = read_safe(gitignore_path).text
|
||||
except Exception:
|
||||
return patterns
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from vibe.core.autocompletion.path_prompt import (
|
||||
PathResource,
|
||||
build_path_prompt_payload,
|
||||
)
|
||||
from vibe.core.utils.io import decode_safe
|
||||
|
||||
DEFAULT_MAX_EMBED_BYTES = 256 * 1024
|
||||
|
||||
@@ -67,11 +68,7 @@ def _try_embed_text_resource(
|
||||
if not _is_probably_text(resource, data):
|
||||
return None
|
||||
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
text = decode_safe(data).text
|
||||
return {"type": "resource", "uri": resource.path.as_uri(), "text": text}
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Annotated, Any, Literal
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from dotenv import dotenv_values
|
||||
from mistralai.client.models import SpeechOutputFormat
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
||||
DEFAULT_TRACES_EXPORT_PATH,
|
||||
)
|
||||
@@ -22,6 +23,7 @@ from pydantic_settings import (
|
||||
PydanticBaseSettingsSource,
|
||||
SettingsConfigDict,
|
||||
)
|
||||
from pydantic_settings.sources.base import deep_update
|
||||
import tomli_w
|
||||
|
||||
from vibe.core.config.harness_files import get_harness_files_manager
|
||||
@@ -96,6 +98,29 @@ class TomlFileSettingsSource(PydanticBaseSettingsSource):
|
||||
return self.toml_data
|
||||
|
||||
|
||||
def _remove_none_values(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: cleaned_value
|
||||
for key, item in value.items()
|
||||
if (cleaned_value := _remove_none_values(item)) is not None
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [
|
||||
cleaned_item
|
||||
for item in value
|
||||
if (cleaned_item := _remove_none_values(item)) is not None
|
||||
]
|
||||
return value
|
||||
|
||||
|
||||
def _to_toml_document(value: Any) -> dict[str, Any]:
|
||||
jsonable = to_jsonable_python(value, fallback=str)
|
||||
if not isinstance(jsonable, dict):
|
||||
return {}
|
||||
return _remove_none_values(jsonable)
|
||||
|
||||
|
||||
class ProjectContextConfig(BaseSettings):
|
||||
model_config = SettingsConfigDict(extra="ignore")
|
||||
|
||||
@@ -121,16 +146,55 @@ class SessionLoggingConfig(BaseSettings):
|
||||
return str(Path(v).expanduser().resolve())
|
||||
|
||||
|
||||
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
|
||||
DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL = "https://console.mistral.ai"
|
||||
DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL = "https://console.mistral.ai/api"
|
||||
|
||||
|
||||
class ProviderConfig(BaseModel):
|
||||
name: str
|
||||
api_base: str
|
||||
api_key_env_var: str = ""
|
||||
browser_auth_base_url: str | None = None
|
||||
browser_auth_api_base_url: str | None = None
|
||||
api_style: str = "openai"
|
||||
backend: Backend = Backend.GENERIC
|
||||
reasoning_field_name: str = "reasoning_content"
|
||||
project_id: str = ""
|
||||
region: str = ""
|
||||
|
||||
def _is_legacy_mistral_provider_without_backend(self) -> bool:
|
||||
return (
|
||||
self.name == "mistral"
|
||||
and self.backend == Backend.GENERIC
|
||||
and "backend" not in self.model_fields_set
|
||||
)
|
||||
|
||||
def _uses_mistral_browser_sign_in_defaults(self) -> bool:
|
||||
return self.name == "mistral" and (
|
||||
self.backend == Backend.MISTRAL
|
||||
or self._is_legacy_mistral_provider_without_backend()
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _apply_legacy_mistral_browser_auth_defaults(self) -> ProviderConfig:
|
||||
if not self._uses_mistral_browser_sign_in_defaults():
|
||||
return self
|
||||
|
||||
if self.browser_auth_base_url is None:
|
||||
self.browser_auth_base_url = DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL
|
||||
if self.browser_auth_api_base_url is None:
|
||||
self.browser_auth_api_base_url = DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL
|
||||
return self
|
||||
|
||||
@property
|
||||
def supports_browser_sign_in(self) -> bool:
|
||||
return (
|
||||
(self.backend == Backend.MISTRAL or self.name == "mistral")
|
||||
and bool(self.browser_auth_base_url)
|
||||
and bool(self.browser_auth_api_base_url)
|
||||
)
|
||||
|
||||
|
||||
class TranscribeClient(StrEnum):
|
||||
MISTRAL = auto()
|
||||
@@ -289,7 +353,7 @@ class TTSModelConfig(BaseModel):
|
||||
provider: str
|
||||
alias: str
|
||||
voice: str = "gb_jane_neutral"
|
||||
response_format: str = "wav"
|
||||
response_format: SpeechOutputFormat = "wav"
|
||||
|
||||
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
|
||||
|
||||
@@ -301,7 +365,6 @@ class OtelSpanExporterConfig(BaseModel):
|
||||
headers: dict[str, str] | None = None
|
||||
|
||||
|
||||
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
|
||||
MISTRAL_OTEL_PATH = "/telemetry"
|
||||
_DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai"
|
||||
|
||||
@@ -310,6 +373,8 @@ DEFAULT_PROVIDERS = [
|
||||
name="mistral",
|
||||
api_base=f"{_DEFAULT_MISTRAL_SERVER_URL}/v1",
|
||||
api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY,
|
||||
browser_auth_base_url=DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL,
|
||||
browser_auth_api_base_url=DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL,
|
||||
backend=Backend.MISTRAL,
|
||||
),
|
||||
ProviderConfig(
|
||||
@@ -343,6 +408,8 @@ DEFAULT_MODELS = [
|
||||
),
|
||||
]
|
||||
|
||||
DEFAULT_ACTIVE_MODEL = DEFAULT_MODELS[0].alias
|
||||
|
||||
DEFAULT_TRANSCRIBE_PROVIDERS = [
|
||||
TranscribeProviderConfig(
|
||||
name="mistral",
|
||||
@@ -375,7 +442,7 @@ DEFAULT_TTS_MODELS = [
|
||||
|
||||
|
||||
class VibeConfig(BaseSettings):
|
||||
active_model: str = "devstral-2"
|
||||
active_model: str = DEFAULT_ACTIVE_MODEL
|
||||
vim_keybindings: bool = False
|
||||
disable_welcome_banner_animation: bool = False
|
||||
autocopy_to_clipboard: bool = True
|
||||
@@ -529,7 +596,8 @@ class VibeConfig(BaseSettings):
|
||||
def otel_span_exporter_config(self) -> OtelSpanExporterConfig | None:
|
||||
# When otel_endpoint is set explicitly, authentication is the user's responsibility
|
||||
# (via OTEL_EXPORTER_OTLP_* env vars), so headers are left empty.
|
||||
# Otherwise endpoint and API key are derived from the first MISTRAL provider.
|
||||
# Otherwise endpoint and API key are derived from the active provider if it's Mistral,
|
||||
# or the first Mistral provider.
|
||||
traces_export_path = DEFAULT_TRACES_EXPORT_PATH.lstrip("/")
|
||||
if self.otel_endpoint:
|
||||
return OtelSpanExporterConfig(
|
||||
@@ -538,9 +606,7 @@ class VibeConfig(BaseSettings):
|
||||
)
|
||||
)
|
||||
|
||||
provider = next(
|
||||
(p for p in self.providers if p.backend == Backend.MISTRAL), None
|
||||
)
|
||||
provider = self.get_mistral_provider()
|
||||
|
||||
if provider is not None:
|
||||
server_url = get_server_url_from_api_base(provider.api_base)
|
||||
@@ -578,7 +644,7 @@ class VibeConfig(BaseSettings):
|
||||
".md"
|
||||
)
|
||||
if custom_sp_path.is_file():
|
||||
return read_safe(custom_sp_path)
|
||||
return read_safe(custom_sp_path).text
|
||||
|
||||
raise MissingPromptFileError(
|
||||
self.system_prompt_id, *(str(d) for d in prompt_dirs)
|
||||
@@ -597,6 +663,15 @@ class VibeConfig(BaseSettings):
|
||||
return self.compaction_model
|
||||
return self.get_active_model()
|
||||
|
||||
def get_mistral_provider(self) -> ProviderConfig | None:
|
||||
try:
|
||||
active_provider = self.get_provider_for_model(self.get_active_model())
|
||||
if active_provider.backend == Backend.MISTRAL:
|
||||
return active_provider
|
||||
except ValueError:
|
||||
pass
|
||||
return next((p for p in self.providers if p.backend == Backend.MISTRAL), None)
|
||||
|
||||
def get_provider_for_model(self, model: ModelConfig) -> ProviderConfig:
|
||||
for provider in self.providers:
|
||||
if provider.name == model.provider:
|
||||
@@ -776,37 +851,8 @@ class VibeConfig(BaseSettings):
|
||||
if not get_harness_files_manager().persist_allowed:
|
||||
return
|
||||
current_config = TomlFileSettingsSource(cls).toml_data
|
||||
|
||||
def deep_merge(target: dict, source: dict) -> None:
|
||||
for key, value in source.items():
|
||||
if (
|
||||
key in target
|
||||
and isinstance(target.get(key), dict)
|
||||
and isinstance(value, dict)
|
||||
):
|
||||
deep_merge(target[key], value)
|
||||
elif (
|
||||
key in target
|
||||
and isinstance(target.get(key), list)
|
||||
and isinstance(value, list)
|
||||
):
|
||||
if key in {
|
||||
"providers",
|
||||
"models",
|
||||
"transcribe_providers",
|
||||
"transcribe_models",
|
||||
"tts_providers",
|
||||
"tts_models",
|
||||
"installed_agents",
|
||||
}:
|
||||
target[key] = value
|
||||
else:
|
||||
target[key] = list(set(value + target[key]))
|
||||
else:
|
||||
target[key] = value
|
||||
|
||||
deep_merge(current_config, updates)
|
||||
cls.dump_config(current_config)
|
||||
merged_config = deep_update(current_config, updates)
|
||||
cls.dump_config(merged_config)
|
||||
|
||||
@classmethod
|
||||
def dump_config(cls, config: dict[str, Any]) -> None:
|
||||
@@ -815,8 +861,10 @@ class VibeConfig(BaseSettings):
|
||||
return
|
||||
target = mgr.config_file or mgr.user_config_file
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
toml_document = _to_toml_document(config)
|
||||
cls.model_validate(toml_document)
|
||||
with target.open("wb") as f:
|
||||
tomli_w.dump(to_jsonable_python(config, exclude_none=True, fallback=str), f)
|
||||
tomli_w.dump(toml_document, f)
|
||||
|
||||
@classmethod
|
||||
def _migrate(cls) -> None:
|
||||
|
||||
@@ -10,7 +10,12 @@ from vibe.core.config.harness_files._paths import (
|
||||
GLOBAL_SKILLS_DIR,
|
||||
GLOBAL_TOOLS_DIR,
|
||||
)
|
||||
from vibe.core.paths import AGENTS_MD_FILENAME, VIBE_HOME, walk_local_config_dirs_all
|
||||
from vibe.core.paths import (
|
||||
AGENTS_MD_FILENAME,
|
||||
VIBE_HOME,
|
||||
ConfigWalkResult,
|
||||
walk_local_config_dirs,
|
||||
)
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
@@ -72,25 +77,23 @@ class HarnessFilesManager:
|
||||
d = GLOBAL_AGENTS_DIR.path
|
||||
return [d] if d.is_dir() else []
|
||||
|
||||
def _walk_project_dirs(
|
||||
self,
|
||||
) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]:
|
||||
def _walk_project_dirs(self) -> ConfigWalkResult:
|
||||
workdir = self.trusted_workdir
|
||||
if workdir is None:
|
||||
return ((), (), ())
|
||||
return walk_local_config_dirs_all(workdir)
|
||||
return ConfigWalkResult()
|
||||
return walk_local_config_dirs(workdir)
|
||||
|
||||
@property
|
||||
def project_tools_dirs(self) -> list[Path]:
|
||||
return list(self._walk_project_dirs()[0])
|
||||
return list(self._walk_project_dirs().tools)
|
||||
|
||||
@property
|
||||
def project_skills_dirs(self) -> list[Path]:
|
||||
return list(self._walk_project_dirs()[1])
|
||||
return list(self._walk_project_dirs().skills)
|
||||
|
||||
@property
|
||||
def project_agents_dirs(self) -> list[Path]:
|
||||
return list(self._walk_project_dirs()[2])
|
||||
return list(self._walk_project_dirs().agents)
|
||||
|
||||
@property
|
||||
def user_config_file(self) -> Path:
|
||||
@@ -116,7 +119,7 @@ class HarnessFilesManager:
|
||||
return ""
|
||||
path = VIBE_HOME.path / AGENTS_MD_FILENAME
|
||||
try:
|
||||
stripped = read_safe(path).strip()
|
||||
stripped = read_safe(path).text.strip()
|
||||
return stripped if stripped else ""
|
||||
except (FileNotFoundError, OSError):
|
||||
return ""
|
||||
@@ -140,7 +143,7 @@ class HarnessFilesManager:
|
||||
break
|
||||
path = current / AGENTS_MD_FILENAME
|
||||
try:
|
||||
stripped = read_safe(path).strip()
|
||||
stripped = read_safe(path).text.strip()
|
||||
if stripped:
|
||||
docs.append((current, stripped))
|
||||
except (FileNotFoundError, OSError):
|
||||
|
||||
@@ -454,7 +454,7 @@ class AnthropicAdapter(APIAdapter):
|
||||
|
||||
return payload
|
||||
|
||||
def prepare_request( # noqa: PLR0913
|
||||
def prepare_request(
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
|
||||
@@ -19,7 +19,7 @@ class PreparedRequest(NamedTuple):
|
||||
class APIAdapter(Protocol):
|
||||
endpoint: ClassVar[str]
|
||||
|
||||
def prepare_request( # noqa: PLR0913
|
||||
def prepare_request(
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
|
||||
@@ -78,7 +78,7 @@ class OpenAIAdapter(APIAdapter):
|
||||
msg_dict["reasoning_content"] = msg_dict.pop(field_name)
|
||||
return msg_dict
|
||||
|
||||
def prepare_request( # noqa: PLR0913
|
||||
def prepare_request(
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
|
||||
@@ -107,7 +107,7 @@ class ReasoningAdapter(APIAdapter):
|
||||
|
||||
return payload
|
||||
|
||||
def prepare_request( # noqa: PLR0913
|
||||
def prepare_request(
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
|
||||
@@ -64,7 +64,7 @@ class VertexAnthropicAdapter(AnthropicAdapter):
|
||||
super().__init__()
|
||||
self.credentials = VertexCredentials()
|
||||
|
||||
def prepare_request( # noqa: PLR0913
|
||||
def prepare_request(
|
||||
self,
|
||||
*,
|
||||
model_name: str,
|
||||
|
||||
@@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator, Callable
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
from jsonpatch import JsonPatch, JsonPatchException # type: ignore[import-untyped]
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from vibe.core.nuage.agent_models import AgentCompletionState
|
||||
@@ -16,7 +17,6 @@ from vibe.core.nuage.events import (
|
||||
CustomTaskTimedOut,
|
||||
JSONPatchAppend,
|
||||
JSONPatchPayload,
|
||||
JSONPatchRemove,
|
||||
JSONPatchReplace,
|
||||
JSONPayload,
|
||||
WorkflowEvent,
|
||||
@@ -61,11 +61,57 @@ from vibe.core.types import (
|
||||
)
|
||||
|
||||
_WAIT_FOR_INPUT_TASK_TYPE = "wait_for_input"
|
||||
_STEER_INPUT_LABEL = "Send a message to steer..."
|
||||
# These names must match the remote workflow's tool naming convention
|
||||
_ASK_USER_QUESTION_TOOL = "ask_user_question"
|
||||
_SEND_USER_MESSAGE_TOOL = "send_user_message"
|
||||
|
||||
|
||||
def _get_value_at_path(path: str, obj: Any) -> Any:
|
||||
if not path or path == "/":
|
||||
return obj
|
||||
parts = path.split("/")[1:]
|
||||
current = obj
|
||||
for part in parts:
|
||||
if current is None:
|
||||
return None
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
elif isinstance(current, list):
|
||||
try:
|
||||
current = current[int(part)]
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
def _set_value_at_path(path: str, obj: Any, value: Any) -> None:
|
||||
if not path or path == "/":
|
||||
return
|
||||
parts = path.split("/")[1:]
|
||||
current = obj
|
||||
for part in parts[:-1]:
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
elif isinstance(current, list):
|
||||
try:
|
||||
current = current[int(part)]
|
||||
except (ValueError, IndexError):
|
||||
return
|
||||
else:
|
||||
return
|
||||
last = parts[-1]
|
||||
if isinstance(current, dict):
|
||||
current[last] = value
|
||||
elif isinstance(current, list):
|
||||
try:
|
||||
current[int(last)] = value
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
|
||||
class _RemoteTool(
|
||||
BaseTool[RemoteToolArgs, RemoteToolResult, BaseToolConfig, BaseToolState],
|
||||
ToolUIData[RemoteToolArgs, RemoteToolResult],
|
||||
@@ -146,6 +192,7 @@ class RemoteWorkflowEventTranslator:
|
||||
self._pending_input_request: PendingInputRequest | None = None
|
||||
self._pending_question_prompt: str | None = None
|
||||
self._pending_ask_user_question_call_id: str | None = None
|
||||
self._steer_task_ids: set[str] = set()
|
||||
self._last_status: WorkflowExecutionStatus | None = None
|
||||
|
||||
@property
|
||||
@@ -315,12 +362,18 @@ class RemoteWorkflowEventTranslator:
|
||||
|
||||
payload_value = event.attributes.payload.value
|
||||
label: str | None = None
|
||||
if isinstance(payload_value, dict):
|
||||
label = payload_value.get("label")
|
||||
|
||||
if label == _STEER_INPUT_LABEL:
|
||||
self._steer_task_ids.add(event.attributes.custom_task_id)
|
||||
return []
|
||||
|
||||
if isinstance(payload_value, dict):
|
||||
self._pending_input_request = PendingInputRequest.model_validate({
|
||||
"task_id": event.attributes.custom_task_id,
|
||||
**payload_value,
|
||||
})
|
||||
label = self._pending_input_request.label
|
||||
|
||||
events: list[BaseEvent] = []
|
||||
if label:
|
||||
@@ -345,6 +398,10 @@ class RemoteWorkflowEventTranslator:
|
||||
if event.attributes.custom_task_type != _WAIT_FOR_INPUT_TASK_TYPE:
|
||||
return None
|
||||
|
||||
if event.attributes.custom_task_id in self._steer_task_ids:
|
||||
self._steer_task_ids.discard(event.attributes.custom_task_id)
|
||||
return []
|
||||
|
||||
self._pending_input_request = None
|
||||
self._pending_question_prompt = None
|
||||
ask_user_question_call_id = self._pending_ask_user_question_call_id
|
||||
@@ -431,53 +488,25 @@ class RemoteWorkflowEventTranslator:
|
||||
self, previous_state: dict[str, Any], payload: JSONPatchPayload
|
||||
) -> dict[str, Any]:
|
||||
new_state = cast(dict[str, Any], self._json_safe_value(previous_state))
|
||||
|
||||
for patch in payload.value:
|
||||
path = [part for part in patch.path.split("/") if part]
|
||||
if not path:
|
||||
if isinstance(patch, JSONPatchReplace):
|
||||
new_state = self._normalize_state(patch.value)
|
||||
continue
|
||||
|
||||
if isinstance(patch, JSONPatchRemove):
|
||||
self._remove_path(new_state, path)
|
||||
continue
|
||||
|
||||
if isinstance(patch, JSONPatchAppend):
|
||||
current = self._get_path(new_state, path)
|
||||
self._set_path(new_state, path, f"{current or ''}{patch.value}")
|
||||
continue
|
||||
|
||||
self._set_path(new_state, path, patch.value)
|
||||
current = _get_value_at_path(patch.path, new_state)
|
||||
_set_value_at_path(
|
||||
patch.path, new_state, f"{current or ''}{patch.value}"
|
||||
)
|
||||
elif isinstance(patch, JSONPatchReplace) and not patch.path.strip("/"):
|
||||
new_state = self._normalize_state(patch.value)
|
||||
else:
|
||||
try:
|
||||
new_state = JsonPatch([
|
||||
{"op": patch.op, "path": patch.path, "value": patch.value}
|
||||
]).apply(new_state)
|
||||
except JsonPatchException:
|
||||
pass
|
||||
|
||||
return new_state
|
||||
|
||||
def _get_path(self, state: dict[str, Any], path: list[str]) -> Any:
|
||||
current: Any = state
|
||||
for part in path:
|
||||
if not isinstance(current, dict):
|
||||
return None
|
||||
current = current.get(part)
|
||||
return current
|
||||
|
||||
def _set_path(self, state: dict[str, Any], path: list[str], value: Any) -> None:
|
||||
current: dict[str, Any] = state
|
||||
for part in path[:-1]:
|
||||
child = current.get(part)
|
||||
if not isinstance(child, dict):
|
||||
child = {}
|
||||
current[part] = child
|
||||
current = child
|
||||
current[path[-1]] = value
|
||||
|
||||
def _remove_path(self, state: dict[str, Any], path: list[str]) -> None:
|
||||
current: dict[str, Any] = state
|
||||
for part in path[:-1]:
|
||||
child = current.get(part)
|
||||
if not isinstance(child, dict):
|
||||
return
|
||||
current = child
|
||||
current.pop(path[-1], None)
|
||||
|
||||
def _completion_events(
|
||||
self, task_id: str, previous_state: dict[str, Any], state: dict[str, Any]
|
||||
) -> list[BaseEvent]:
|
||||
|
||||
@@ -2,8 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from vibe.core.paths._local_config_walk import (
|
||||
WALK_MAX_DEPTH,
|
||||
has_config_dirs_nearby,
|
||||
walk_local_config_dirs_all,
|
||||
ConfigWalkResult,
|
||||
walk_local_config_dirs,
|
||||
)
|
||||
from vibe.core.paths._vibe_home import (
|
||||
DEFAULT_TOOL_DIR,
|
||||
@@ -31,7 +31,7 @@ __all__ = [
|
||||
"TRUSTED_FOLDERS_FILE",
|
||||
"VIBE_HOME",
|
||||
"WALK_MAX_DEPTH",
|
||||
"ConfigWalkResult",
|
||||
"GlobalPath",
|
||||
"has_config_dirs_nearby",
|
||||
"walk_local_config_dirs_all",
|
||||
"walk_local_config_dirs",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from functools import cache
|
||||
import logging
|
||||
import os
|
||||
@@ -21,24 +22,59 @@ WALK_MAX_DEPTH = 4
|
||||
_MAX_DIRS = 2000
|
||||
|
||||
|
||||
def _collect_config_dirs_at(
|
||||
path: Path,
|
||||
entry_names: set[str],
|
||||
tools: list[Path],
|
||||
skills: list[Path],
|
||||
agents: list[Path],
|
||||
@dataclass(frozen=True)
|
||||
class ConfigWalkResult:
|
||||
"""Aggregated results of a config directory walk."""
|
||||
|
||||
config_dirs: tuple[Path, ...] = ()
|
||||
tools: tuple[Path, ...] = ()
|
||||
skills: tuple[Path, ...] = ()
|
||||
agents: tuple[Path, ...] = ()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ConfigWalkCollector:
|
||||
"""Mutable accumulator used during BFS, frozen into ConfigWalkResult at the end."""
|
||||
|
||||
config_dirs: list[Path] = field(default_factory=list)
|
||||
tools: list[Path] = field(default_factory=list)
|
||||
skills: list[Path] = field(default_factory=list)
|
||||
agents: list[Path] = field(default_factory=list)
|
||||
|
||||
def freeze(self) -> ConfigWalkResult:
|
||||
return ConfigWalkResult(
|
||||
config_dirs=tuple(self.config_dirs),
|
||||
tools=tuple(self.tools),
|
||||
skills=tuple(self.skills),
|
||||
agents=tuple(self.agents),
|
||||
)
|
||||
|
||||
|
||||
def _collect_at(
|
||||
path: Path, entry_names: set[str], collector: _ConfigWalkCollector
|
||||
) -> None:
|
||||
"""Check a single directory for .vibe/ and .agents/ config subdirs."""
|
||||
if _VIBE_DIR in entry_names:
|
||||
if _VIBE_DIR in entry_names and (vibe_dir := path / _VIBE_DIR).is_dir():
|
||||
has_content = False
|
||||
if (candidate := path / _TOOLS_SUBDIR).is_dir():
|
||||
tools.append(candidate)
|
||||
collector.tools.append(candidate)
|
||||
has_content = True
|
||||
if (candidate := path / _VIBE_SKILLS_SUBDIR).is_dir():
|
||||
skills.append(candidate)
|
||||
collector.skills.append(candidate)
|
||||
has_content = True
|
||||
if (candidate := path / _AGENTS_SUBDIR).is_dir():
|
||||
agents.append(candidate)
|
||||
if _AGENTS_DIR in entry_names:
|
||||
collector.agents.append(candidate)
|
||||
has_content = True
|
||||
if (
|
||||
has_content
|
||||
or (vibe_dir / "prompts").is_dir()
|
||||
or (vibe_dir / "config.toml").is_file()
|
||||
):
|
||||
collector.config_dirs.append(vibe_dir)
|
||||
if _AGENTS_DIR in entry_names and (agents_dir := path / _AGENTS_DIR).is_dir():
|
||||
if (candidate := path / _AGENTS_SKILLS_SUBDIR).is_dir():
|
||||
skills.append(candidate)
|
||||
collector.skills.append(candidate)
|
||||
collector.config_dirs.append(agents_dir)
|
||||
|
||||
|
||||
def _scandir_entries(path: Path) -> tuple[set[str], list[Path]]:
|
||||
@@ -68,60 +104,22 @@ def _scandir_entries(path: Path) -> tuple[set[str], list[Path]]:
|
||||
|
||||
|
||||
@cache
|
||||
def walk_local_config_dirs_all(
|
||||
root: Path,
|
||||
) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]:
|
||||
def walk_local_config_dirs(
|
||||
root: Path, *, max_depth: int = WALK_MAX_DEPTH, max_dirs: int = _MAX_DIRS
|
||||
) -> ConfigWalkResult:
|
||||
"""Discover .vibe/ and .agents/ config directories under *root*.
|
||||
|
||||
Uses breadth-first search bounded by ``WALK_MAX_DEPTH`` and ``_MAX_DIRS``
|
||||
Uses breadth-first search bounded by *max_depth* and *max_dirs*
|
||||
to avoid unbounded traversal in large repositories.
|
||||
"""
|
||||
tools_dirs: list[Path] = []
|
||||
skills_dirs: list[Path] = []
|
||||
agents_dirs: list[Path] = []
|
||||
|
||||
Returns a ``ConfigWalkResult`` containing both the parent config dirs
|
||||
(for trust decisions) and the categorised subdirs (for loading).
|
||||
"""
|
||||
collector = _ConfigWalkCollector()
|
||||
resolved_root = root.resolve()
|
||||
queue: deque[tuple[Path, int]] = deque([(resolved_root, 0)])
|
||||
visited = 0
|
||||
|
||||
while queue and visited < _MAX_DIRS:
|
||||
current, depth = queue.popleft()
|
||||
visited += 1
|
||||
|
||||
entry_names, children = _scandir_entries(current)
|
||||
if not entry_names:
|
||||
continue
|
||||
|
||||
_collect_config_dirs_at(
|
||||
current, entry_names, tools_dirs, skills_dirs, agents_dirs
|
||||
)
|
||||
|
||||
if depth < WALK_MAX_DEPTH:
|
||||
queue.extend((child, depth + 1) for child in children)
|
||||
|
||||
if visited >= _MAX_DIRS:
|
||||
logger.warning(
|
||||
"Config directory scan reached directory limit (%d dirs) at %s",
|
||||
_MAX_DIRS,
|
||||
resolved_root,
|
||||
)
|
||||
|
||||
return (tuple(tools_dirs), tuple(skills_dirs), tuple(agents_dirs))
|
||||
|
||||
|
||||
def has_config_dirs_nearby(
|
||||
root: Path, *, max_depth: int = WALK_MAX_DEPTH, max_dirs: int = 200
|
||||
) -> bool:
|
||||
"""Quick check for .vibe/ or .agents/ config dirs in the near subtree.
|
||||
|
||||
Returns ``True`` as soon as any config directory is found, without
|
||||
enumerating all of them.
|
||||
"""
|
||||
resolved = root.resolve()
|
||||
queue: deque[tuple[Path, int]] = deque([(resolved, 0)])
|
||||
visited = 0
|
||||
found: list[Path] = []
|
||||
|
||||
while queue and visited < max_dirs:
|
||||
current, depth = queue.popleft()
|
||||
visited += 1
|
||||
@@ -130,11 +128,16 @@ def has_config_dirs_nearby(
|
||||
if not entry_names:
|
||||
continue
|
||||
|
||||
_collect_config_dirs_at(current, entry_names, found, found, found)
|
||||
if found:
|
||||
return True
|
||||
_collect_at(current, entry_names, collector)
|
||||
|
||||
if depth < max_depth:
|
||||
queue.extend((child, depth + 1) for child in children)
|
||||
|
||||
return False
|
||||
if visited >= max_dirs:
|
||||
logger.warning(
|
||||
"Config directory scan reached directory limit (%d dirs) at %s",
|
||||
max_dirs,
|
||||
resolved_root,
|
||||
)
|
||||
|
||||
return collector.freeze()
|
||||
|
||||
@@ -15,7 +15,7 @@ class Prompt(StrEnum):
|
||||
return (_PROMPTS_DIR / self.value).with_suffix(".md")
|
||||
|
||||
def read(self) -> str:
|
||||
return read_safe(self.path).strip()
|
||||
return read_safe(self.path).text.strip()
|
||||
|
||||
|
||||
class SystemPrompt(Prompt):
|
||||
|
||||
@@ -34,21 +34,19 @@ class SessionLoader:
|
||||
return False
|
||||
|
||||
try:
|
||||
with metadata_path.open("r", encoding="utf-8", errors="ignore") as f:
|
||||
metadata = json.load(f)
|
||||
metadata = json.loads(read_safe(metadata_path).text)
|
||||
if not isinstance(metadata, dict):
|
||||
return False
|
||||
|
||||
with messages_path.open("r", encoding="utf-8", errors="ignore") as f:
|
||||
has_messages = False
|
||||
for line in f:
|
||||
has_messages = True
|
||||
message = json.loads(line)
|
||||
if not isinstance(message, dict):
|
||||
return False
|
||||
has_messages = False
|
||||
for line in read_safe(messages_path).text.splitlines():
|
||||
has_messages = True
|
||||
message = json.loads(line)
|
||||
if not isinstance(message, dict):
|
||||
return False
|
||||
if not has_messages:
|
||||
return False
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -144,8 +142,7 @@ class SessionLoader:
|
||||
|
||||
metadata_path = session_dir / METADATA_FILENAME
|
||||
try:
|
||||
with metadata_path.open("r", encoding="utf-8") as f:
|
||||
metadata = json.load(f)
|
||||
metadata = json.loads(read_safe(metadata_path).text)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
@@ -182,7 +179,7 @@ class SessionLoader:
|
||||
raise ValueError(f"Session metadata not found at {session_dir}")
|
||||
|
||||
try:
|
||||
metadata_content = read_safe(metadata_path)
|
||||
metadata_content = read_safe(metadata_path).text
|
||||
return SessionMetadata.model_validate_json(metadata_content)
|
||||
except ValueError:
|
||||
raise
|
||||
@@ -197,7 +194,7 @@ class SessionLoader:
|
||||
messages_filepath = filepath / MESSAGES_FILENAME
|
||||
|
||||
try:
|
||||
content = read_safe(messages_filepath).split("\n")
|
||||
content = read_safe(messages_filepath).text.split("\n")
|
||||
if content and content[-1] == "":
|
||||
content.pop()
|
||||
except Exception as e:
|
||||
@@ -228,10 +225,7 @@ class SessionLoader:
|
||||
|
||||
if metadata_filepath.exists():
|
||||
try:
|
||||
with metadata_filepath.open(
|
||||
"r", encoding="utf-8", errors="ignore"
|
||||
) as f:
|
||||
metadata = json.load(f)
|
||||
metadata = json.loads(read_safe(metadata_filepath).text)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(
|
||||
f"Session metadata contains invalid JSON (may have been corrupted): "
|
||||
|
||||
@@ -231,7 +231,7 @@ class SessionLogger:
|
||||
# Read old metadata and get total_messages
|
||||
try:
|
||||
if self.metadata_filepath.exists():
|
||||
raw = await read_safe_async(self.metadata_filepath)
|
||||
raw = (await read_safe_async(self.metadata_filepath)).text
|
||||
old_metadata = json.loads(raw)
|
||||
old_total_messages = old_metadata["total_messages"]
|
||||
else:
|
||||
|
||||
@@ -23,7 +23,7 @@ async def migrate_sessions(session_config: SessionLoggingConfig) -> int:
|
||||
session_files = list(Path(save_dir).glob(f"{session_config.session_prefix}_*.json"))
|
||||
for session_file in session_files:
|
||||
try:
|
||||
session_data = read_safe(session_file)
|
||||
session_data = read_safe(session_file).text
|
||||
session_json = json.loads(session_data)
|
||||
metadata = session_json["metadata"]
|
||||
messages = session_json["messages"]
|
||||
|
||||
@@ -107,7 +107,7 @@ class SkillManager:
|
||||
|
||||
def _parse_skill_file(self, skill_path: Path) -> SkillInfo:
|
||||
try:
|
||||
content = read_safe(skill_path)
|
||||
content = read_safe(skill_path).text
|
||||
except OSError as e:
|
||||
raise SkillParseError(f"Cannot read file: {e}") from e
|
||||
|
||||
|
||||
@@ -136,8 +136,8 @@ class ProjectContextProvider:
|
||||
except Exception as e:
|
||||
return f"Error getting git status: {e}"
|
||||
|
||||
def get_full_context(self) -> str:
|
||||
git_status = self.get_git_status()
|
||||
def get_full_context(self, *, include_git_status: bool = True) -> str:
|
||||
git_status = self.get_git_status() if include_git_status else ""
|
||||
|
||||
template = UtilityPrompt.PROJECT_CONTEXT.read()
|
||||
return Template(template).safe_substitute(
|
||||
@@ -249,6 +249,8 @@ def get_universal_system_prompt(
|
||||
config: VibeConfig,
|
||||
skill_manager: SkillManager,
|
||||
agent_manager: AgentManager,
|
||||
*,
|
||||
include_git_status: bool = True,
|
||||
) -> str:
|
||||
sections = [config.system_prompt]
|
||||
|
||||
@@ -285,7 +287,7 @@ def get_universal_system_prompt(
|
||||
else:
|
||||
context = ProjectContextProvider(
|
||||
config=config.project_context, root_path=Path.cwd()
|
||||
).get_full_context()
|
||||
).get_full_context(include_git_status=include_git_status)
|
||||
|
||||
sections.append(context)
|
||||
|
||||
|
||||
@@ -4,19 +4,20 @@ import asyncio
|
||||
from collections.abc import Callable
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.config import ProviderConfig, VibeConfig
|
||||
from vibe.core.llm.format import ResolvedToolCall
|
||||
from vibe.core.types import Backend
|
||||
from vibe.core.utils import get_user_agent
|
||||
from vibe.core.utils import get_server_url_from_api_base, get_user_agent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.agent_loop import ToolDecision
|
||||
|
||||
DATALAKE_EVENTS_URL = "https://codestral.mistral.ai/v1/datalake/events"
|
||||
_DEFAULT_TELEMETRY_BASE_URL = "https://codestral.mistral.ai"
|
||||
_DATALAKE_EVENTS_PATH = "/v1/datalake/events"
|
||||
|
||||
|
||||
class TelemetryClient:
|
||||
@@ -31,31 +32,35 @@ class TelemetryClient:
|
||||
self._pending_tasks: set[asyncio.Task[Any]] = set()
|
||||
self.last_correlation_id: str | None = None
|
||||
|
||||
def _get_telemetry_user_agent(self) -> str:
|
||||
try:
|
||||
config = self._config_getter()
|
||||
active_model = config.get_active_model()
|
||||
provider = config.get_provider_for_model(active_model)
|
||||
return get_user_agent(provider.backend)
|
||||
except ValueError:
|
||||
return get_user_agent(None)
|
||||
def _get_telemetry_url(self, api_base: str) -> str:
|
||||
base = get_server_url_from_api_base(api_base) or _DEFAULT_TELEMETRY_BASE_URL
|
||||
return urljoin(base.rstrip("/"), _DATALAKE_EVENTS_PATH)
|
||||
|
||||
def _get_mistral_api_key(self) -> str | None:
|
||||
"""Get the current API key from the active provider.
|
||||
"""Get the API key from the active provider if it's Mistral,
|
||||
otherwise the first Mistral provider.
|
||||
|
||||
Only returns an API key if the provider is a Mistral provider
|
||||
to avoid leaking third-party credentials to the telemetry endpoint.
|
||||
"""
|
||||
provider_and_api_key = self._get_mistral_provider_and_api_key()
|
||||
if provider_and_api_key is None:
|
||||
return None
|
||||
_, api_key = provider_and_api_key
|
||||
return api_key
|
||||
|
||||
def _get_mistral_provider_and_api_key(self) -> tuple[ProviderConfig, str] | None:
|
||||
try:
|
||||
config = self._config_getter()
|
||||
model = config.get_active_model()
|
||||
provider = config.get_provider_for_model(model)
|
||||
if provider.backend != Backend.MISTRAL:
|
||||
return None
|
||||
env_var = provider.api_key_env_var
|
||||
return os.getenv(env_var) if env_var else None
|
||||
provider = self._config_getter().get_mistral_provider()
|
||||
except ValueError:
|
||||
return None
|
||||
if provider is None:
|
||||
return None
|
||||
env_var = provider.api_key_env_var
|
||||
api_key = os.getenv(env_var) if env_var else None
|
||||
if api_key is None:
|
||||
return None
|
||||
return provider, api_key
|
||||
|
||||
def _is_enabled(self) -> bool:
|
||||
"""Check if telemetry is enabled in the current config."""
|
||||
@@ -83,10 +88,14 @@ class TelemetryClient:
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
) -> None:
|
||||
mistral_api_key = self._get_mistral_api_key()
|
||||
if mistral_api_key is None or not self._is_enabled():
|
||||
if not self._is_enabled():
|
||||
return
|
||||
user_agent = self._get_telemetry_user_agent()
|
||||
provider_and_api_key = self._get_mistral_provider_and_api_key()
|
||||
if provider_and_api_key is None:
|
||||
return
|
||||
provider, mistral_api_key = provider_and_api_key
|
||||
telemetry_url = self._get_telemetry_url(provider.api_base)
|
||||
user_agent = get_user_agent(provider.backend)
|
||||
if (
|
||||
self._session_id_getter is not None
|
||||
and (session_id := self._session_id_getter()) is not None
|
||||
@@ -100,7 +109,7 @@ class TelemetryClient:
|
||||
async def _send() -> None:
|
||||
try:
|
||||
await self.client.post(
|
||||
DATALAKE_EVENTS_URL,
|
||||
telemetry_url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -134,7 +134,9 @@ class GitRepository:
|
||||
try:
|
||||
self._repo = Repo(self._workdir, search_parent_directories=True)
|
||||
except InvalidGitRepositoryError as e:
|
||||
raise ServiceTeleportNotSupportedError("Not a git repository") from e
|
||||
raise ServiceTeleportNotSupportedError(
|
||||
"Teleport requires a git repository. cd into a project with a .git directory and try again."
|
||||
) from e
|
||||
return self._repo
|
||||
|
||||
def _find_github_remote(self, repo: Repo) -> tuple[str, str] | None:
|
||||
|
||||
@@ -167,7 +167,7 @@ class BaseTool[
|
||||
prompt_dir = class_path.parent / "prompts"
|
||||
prompt_path = cls.prompt_path or prompt_dir / f"{class_path.stem}.md"
|
||||
|
||||
return read_safe(prompt_path)
|
||||
return read_safe(prompt_path).text
|
||||
except (FileNotFoundError, TypeError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class ExitPlanMode(
|
||||
plan_content: str | None = None
|
||||
if ctx.plan_file_path and ctx.plan_file_path.is_file():
|
||||
try:
|
||||
plan_content = read_safe(ctx.plan_file_path)
|
||||
plan_content = read_safe(ctx.plan_file_path).text
|
||||
except OSError as e:
|
||||
raise ToolError(
|
||||
f"Failed to read plan file at {ctx.plan_file_path}: {e}"
|
||||
|
||||
@@ -167,7 +167,7 @@ class Grep(
|
||||
def _load_codeignore_patterns(self, codeignore_path: Path) -> list[str]:
|
||||
patterns = []
|
||||
try:
|
||||
content = read_safe(codeignore_path)
|
||||
content = read_safe(codeignore_path).text
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
|
||||
@@ -21,6 +21,7 @@ from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.tools.utils import resolve_file_tool_permission
|
||||
from vibe.core.types import ToolStreamEvent
|
||||
from vibe.core.utils import VIBE_WARNING_TAG
|
||||
from vibe.core.utils.io import decode_safe
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.types import ToolResultEvent
|
||||
@@ -73,8 +74,8 @@ class ReadFile(
|
||||
ToolUIData[ReadFileArgs, ReadFileResult],
|
||||
):
|
||||
description: ClassVar[str] = (
|
||||
"Read a UTF-8 file, returning content from a specific line range. "
|
||||
"Reading is capped by a byte limit for safety."
|
||||
"Read a text file (encoding detected safely), returning content from a "
|
||||
"specific line range. Reading is capped by a byte limit for safety."
|
||||
)
|
||||
|
||||
@final
|
||||
@@ -135,53 +136,38 @@ class ReadFile(
|
||||
|
||||
async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult:
|
||||
try:
|
||||
return await self._do_read_file(args, file_path, encoding="utf-8")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return await self._do_read_file(args, file_path, errors="replace")
|
||||
|
||||
async def _do_read_file(
|
||||
self,
|
||||
args: ReadFileArgs,
|
||||
file_path: Path,
|
||||
*,
|
||||
encoding: str | None = None,
|
||||
errors: str | None = None,
|
||||
) -> _ReadResult:
|
||||
try:
|
||||
lines_to_return: list[str] = []
|
||||
raw_lines: list[bytes] = []
|
||||
bytes_read = 0
|
||||
was_truncated = False
|
||||
|
||||
async with await anyio.Path(file_path).open(
|
||||
encoding=encoding, errors=errors
|
||||
) as f:
|
||||
async with await anyio.Path(file_path).open("rb") as f:
|
||||
line_index = 0
|
||||
async for line in f:
|
||||
while raw_line := await f.readline():
|
||||
if line_index < args.offset:
|
||||
line_index += 1
|
||||
continue
|
||||
|
||||
if args.limit is not None and len(lines_to_return) >= args.limit:
|
||||
if args.limit is not None and len(raw_lines) >= args.limit:
|
||||
break
|
||||
|
||||
line_bytes = len(line.encode("utf-8"))
|
||||
line_bytes = len(raw_line)
|
||||
if bytes_read + line_bytes > self.config.max_read_bytes:
|
||||
was_truncated = True
|
||||
break
|
||||
|
||||
lines_to_return.append(line)
|
||||
raw_lines.append(raw_line)
|
||||
bytes_read += line_bytes
|
||||
line_index += 1
|
||||
|
||||
return _ReadResult(
|
||||
lines=lines_to_return,
|
||||
bytes_read=bytes_read,
|
||||
was_truncated=was_truncated,
|
||||
)
|
||||
|
||||
except OSError as exc:
|
||||
raise ToolError(f"Error reading {file_path}: {exc}") from exc
|
||||
|
||||
lines_to_return = decode_safe(b"".join(raw_lines)).text.splitlines(
|
||||
keepends=True
|
||||
)
|
||||
return _ReadResult(
|
||||
lines=lines_to_return, bytes_read=bytes_read, was_truncated=was_truncated
|
||||
)
|
||||
|
||||
def _validate_inputs(self, args: ReadFileArgs) -> None:
|
||||
if not args.path.strip():
|
||||
raise ToolError("Path cannot be empty")
|
||||
|
||||
@@ -22,7 +22,7 @@ from vibe.core.tools.permissions import PermissionContext
|
||||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.tools.utils import resolve_file_tool_permission
|
||||
from vibe.core.types import ToolResultEvent, ToolStreamEvent
|
||||
from vibe.core.utils.io import read_safe_async
|
||||
from vibe.core.utils.io import ReadSafeResult, read_safe_async
|
||||
|
||||
SEARCH_REPLACE_BLOCK_RE = re.compile(
|
||||
r"<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE", flags=re.DOTALL
|
||||
@@ -131,7 +131,8 @@ class SearchReplace(
|
||||
) -> AsyncGenerator[ToolStreamEvent | SearchReplaceResult, None]:
|
||||
file_path, search_replace_blocks = self._prepare_and_validate_args(args)
|
||||
|
||||
original_content = await self._read_file(file_path)
|
||||
decoded = await self._read_file(file_path)
|
||||
original_content = decoded.text
|
||||
|
||||
block_result = self._apply_blocks(
|
||||
original_content,
|
||||
@@ -166,7 +167,7 @@ class SearchReplace(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await self._write_file(file_path, modified_content)
|
||||
await self._write_file(file_path, modified_content, decoded.encoding)
|
||||
|
||||
yield SearchReplaceResult(
|
||||
file=str(file_path),
|
||||
@@ -221,7 +222,7 @@ class SearchReplace(
|
||||
|
||||
return file_path, search_replace_blocks
|
||||
|
||||
async def _read_file(self, file_path: Path) -> str:
|
||||
async def _read_file(self, file_path: Path) -> ReadSafeResult:
|
||||
try:
|
||||
return await read_safe_async(file_path, raise_on_error=True)
|
||||
except PermissionError:
|
||||
@@ -234,12 +235,16 @@ class SearchReplace(
|
||||
async def _backup_file(self, file_path: Path) -> None:
|
||||
shutil.copy2(file_path, file_path.with_suffix(file_path.suffix + ".bak"))
|
||||
|
||||
async def _write_file(self, file_path: Path, content: str) -> None:
|
||||
async def _write_file(self, file_path: Path, content: str, encoding: str) -> None:
|
||||
try:
|
||||
async with await anyio.Path(file_path).open(
|
||||
mode="w", encoding="utf-8"
|
||||
mode="w", encoding=encoding
|
||||
) as f:
|
||||
await f.write(content)
|
||||
except UnicodeEncodeError as e:
|
||||
raise ToolError(
|
||||
f"Cannot encode patched content for {file_path} using {encoding!r}: {e}"
|
||||
) from e
|
||||
except PermissionError:
|
||||
raise ToolError(f"Permission denied writing to file: {file_path}")
|
||||
except OSError as e:
|
||||
|
||||
@@ -99,7 +99,7 @@ class Skill(
|
||||
)
|
||||
|
||||
try:
|
||||
raw = read_safe(skill_info.skill_path)
|
||||
raw = read_safe(skill_info.skill_path).text
|
||||
_, body = parse_frontmatter(raw)
|
||||
except (OSError, SkillParseError) as e:
|
||||
raise ToolError(f"Cannot load skill file: {e}") from e
|
||||
|
||||
@@ -7,6 +7,7 @@ import inspect
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from vibe.core.config.harness_files import get_harness_files_manager
|
||||
@@ -69,16 +70,21 @@ class ToolManager:
|
||||
self,
|
||||
config_getter: Callable[[], VibeConfig],
|
||||
mcp_registry: MCPRegistry | None = None,
|
||||
*,
|
||||
defer_mcp: bool = False,
|
||||
) -> None:
|
||||
self._config_getter = config_getter
|
||||
self._mcp_registry = mcp_registry or MCPRegistry()
|
||||
self._instances: dict[str, BaseTool] = {}
|
||||
self._search_paths: list[Path] = self._compute_search_paths(self._config)
|
||||
self._lock = threading.Lock()
|
||||
self._mcp_integrated = False
|
||||
|
||||
self._available: dict[str, type[BaseTool]] = {
|
||||
cls.get_name(): cls for cls in self._iter_tool_classes(self._search_paths)
|
||||
}
|
||||
self._integrate_mcp()
|
||||
if not defer_mcp:
|
||||
self.integrate_mcp()
|
||||
|
||||
@property
|
||||
def _config(self) -> VibeConfig:
|
||||
@@ -176,13 +182,15 @@ class ToolManager:
|
||||
|
||||
@property
|
||||
def registered_tools(self) -> dict[str, type[BaseTool]]:
|
||||
return self._available
|
||||
with self._lock:
|
||||
return dict(self._available)
|
||||
|
||||
@property
|
||||
def available_tools(self) -> dict[str, type[BaseTool]]:
|
||||
runtime_available = {
|
||||
name: cls for name, cls in self._available.items() if cls.is_available()
|
||||
}
|
||||
with self._lock:
|
||||
runtime_available = {
|
||||
name: cls for name, cls in self._available.items() if cls.is_available()
|
||||
}
|
||||
|
||||
if self._config.enabled_tools:
|
||||
return {
|
||||
@@ -198,7 +206,14 @@ class ToolManager:
|
||||
}
|
||||
return runtime_available
|
||||
|
||||
def _integrate_mcp(self) -> None:
|
||||
def integrate_mcp(self, *, raise_on_failure: bool = False) -> None:
|
||||
"""Discover and register MCP tools.
|
||||
|
||||
Idempotent: subsequent calls after a successful integration are
|
||||
no-ops to avoid redundant MCP discovery.
|
||||
"""
|
||||
if self._mcp_integrated:
|
||||
return
|
||||
if not self._config.mcp_servers:
|
||||
return
|
||||
|
||||
@@ -206,15 +221,20 @@ class ToolManager:
|
||||
mcp_tools = self._mcp_registry.get_tools(self._config.mcp_servers)
|
||||
except Exception as exc:
|
||||
logger.warning("MCP integration failed: %s", exc)
|
||||
if raise_on_failure:
|
||||
raise
|
||||
return
|
||||
|
||||
self._available.update(mcp_tools)
|
||||
with self._lock:
|
||||
self._available = {**self._available, **mcp_tools}
|
||||
self._mcp_integrated = True
|
||||
logger.info(
|
||||
"MCP integration registered %d tools (via registry)", len(mcp_tools)
|
||||
)
|
||||
|
||||
def get_tool_config(self, tool_name: str) -> BaseToolConfig:
|
||||
tool_class = self._available.get(tool_name)
|
||||
with self._lock:
|
||||
tool_class = self._available.get(tool_name)
|
||||
|
||||
if tool_class:
|
||||
config_class = tool_class._get_tool_config_class()
|
||||
@@ -239,12 +259,12 @@ class ToolManager:
|
||||
if tool_name in self._instances:
|
||||
return self._instances[tool_name]
|
||||
|
||||
if tool_name not in self._available:
|
||||
raise NoSuchToolError(
|
||||
f"Unknown tool: {tool_name}. Available: {list(self._available.keys())}"
|
||||
)
|
||||
|
||||
tool_class = self._available[tool_name]
|
||||
with self._lock:
|
||||
if tool_name not in self._available:
|
||||
raise NoSuchToolError(
|
||||
f"Unknown tool: {tool_name}. Available: {list(self._available.keys())}"
|
||||
)
|
||||
tool_class = self._available[tool_name]
|
||||
self._instances[tool_name] = tool_class.from_config(
|
||||
lambda: self.get_tool_config(tool_name)
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import tomli_w
|
||||
from vibe.core.paths import (
|
||||
AGENTS_MD_FILENAME,
|
||||
TRUSTED_FOLDERS_FILE,
|
||||
has_config_dirs_nearby,
|
||||
walk_local_config_dirs,
|
||||
)
|
||||
|
||||
|
||||
@@ -16,12 +16,20 @@ def has_agents_md_file(path: Path) -> bool:
|
||||
return (path / AGENTS_MD_FILENAME).exists()
|
||||
|
||||
|
||||
def has_trustable_content(path: Path) -> bool:
|
||||
if (path / ".vibe").exists():
|
||||
return True
|
||||
def find_trustable_files(path: Path) -> list[str]:
|
||||
"""Return relative paths of files/dirs that would modify the agent's behavior."""
|
||||
resolved = path.resolve()
|
||||
found: list[str] = []
|
||||
|
||||
if has_agents_md_file(path):
|
||||
return True
|
||||
return has_config_dirs_nearby(path)
|
||||
found.append(AGENTS_MD_FILENAME)
|
||||
|
||||
for config_dir in walk_local_config_dirs(path).config_dirs:
|
||||
label = f"{config_dir.relative_to(resolved)}/"
|
||||
if label not in found:
|
||||
found.append(label)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
class TrustedFoldersManager:
|
||||
|
||||
@@ -3,7 +3,8 @@ from __future__ import annotations
|
||||
import base64
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from mistralai.client import Mistral
|
||||
from mistralai.client.models import SpeechOutputFormat
|
||||
|
||||
from vibe.core.config import TTSModelConfig, TTSProviderConfig
|
||||
from vibe.core.tts.tts_client_port import TTSResult
|
||||
@@ -11,34 +12,30 @@ from vibe.core.tts.tts_client_port import TTSResult
|
||||
|
||||
class MistralTTSClient:
|
||||
def __init__(self, provider: TTSProviderConfig, model: TTSModelConfig) -> None:
|
||||
self._api_key = os.getenv(provider.api_key_env_var, "")
|
||||
self._server_url = provider.api_base
|
||||
self._model_name = model.name
|
||||
self._voice = model.voice
|
||||
self._response_format = model.response_format
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=f"{provider.api_base}/v1",
|
||||
headers={
|
||||
"Authorization": f"Bearer {os.getenv(provider.api_key_env_var, '')}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=60.0,
|
||||
)
|
||||
self._response_format: SpeechOutputFormat = model.response_format
|
||||
self._client: Mistral | None = None
|
||||
|
||||
def _get_client(self) -> Mistral:
|
||||
if self._client is None:
|
||||
self._client = Mistral(api_key=self._api_key, server_url=self._server_url)
|
||||
return self._client
|
||||
|
||||
async def speak(self, text: str) -> TTSResult:
|
||||
response = await self._client.post(
|
||||
"/audio/speech",
|
||||
json={
|
||||
"model": self._model_name,
|
||||
"input": text,
|
||||
"voice_id": self._voice,
|
||||
"stream": False,
|
||||
"response_format": self._response_format,
|
||||
},
|
||||
client = self._get_client()
|
||||
response = await client.audio.speech.complete_async(
|
||||
model=self._model_name,
|
||||
input=text,
|
||||
voice_id=self._voice,
|
||||
response_format=self._response_format,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
audio_bytes = base64.b64decode(data["audio_data"])
|
||||
audio_bytes = base64.b64decode(response.audio_data)
|
||||
return TTSResult(audio_data=audio_bytes)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
if self._client is not None:
|
||||
await self._client.__aexit__(exc_type=None, exc_val=None, exc_tb=None)
|
||||
self._client = None
|
||||
|
||||
@@ -487,7 +487,13 @@ class MessageList(Sequence[LLMMessage]):
|
||||
hook()
|
||||
|
||||
def update_system_prompt(self, new: str) -> None:
|
||||
"""Update the system prompt in place."""
|
||||
"""Update the system prompt in place.
|
||||
|
||||
Called from a background thread during deferred init. A single
|
||||
list-item assignment is atomic under CPython's GIL, and the
|
||||
``_init_complete`` event ensures no ``act()`` call reads the
|
||||
prompt concurrently, so no additional lock is needed here.
|
||||
"""
|
||||
self._data[0] = LLMMessage(role=Role.system, content=new)
|
||||
|
||||
@contextmanager
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Utilities package. Re-exports all public and test-used symbols from submodules.
|
||||
|
||||
Import read_safe/read_safe_async from vibe.core.utils.io and create_slug from
|
||||
Import read_safe / read_safe_async / decode_safe (returns ReadSafeResult) from vibe.core.utils.io and create_slug from
|
||||
vibe.core.utils.slug when needed to avoid circular imports with config.
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,29 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
import locale
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
import anyio
|
||||
from charset_normalizer import from_bytes
|
||||
|
||||
|
||||
def read_safe(path: Path, *, raise_on_error: bool = False) -> str:
|
||||
"""Read a text file trying UTF-8 first, falling back to OS-default encoding.
|
||||
class ReadSafeResult(NamedTuple):
|
||||
"""Text decoded from a file and the codec name that successfully decoded it."""
|
||||
|
||||
On fallback, undecodable bytes are replaced with U+FFFD (REPLACEMENT CHARACTER).
|
||||
When raise_on_error is True, decode errors propagate.
|
||||
text: str
|
||||
encoding: str
|
||||
|
||||
|
||||
def _encodings_from_bom(raw: bytes) -> str | None:
|
||||
if raw.startswith(b"\xef\xbb\xbf"):
|
||||
return "utf-8-sig"
|
||||
if raw.startswith(b"\xff\xfe\x00\x00"):
|
||||
return "utf-32-le"
|
||||
if raw.startswith(b"\x00\x00\xfe\xff"):
|
||||
return "utf-32-be"
|
||||
if raw.startswith(b"\xff\xfe"):
|
||||
return "utf-16-le"
|
||||
if raw.startswith(b"\xfe\xff"):
|
||||
return "utf-16-be"
|
||||
return None
|
||||
|
||||
|
||||
def _encoding_from_best_match(raw: bytes) -> str | None:
|
||||
if not (match := from_bytes(raw).best()):
|
||||
return None
|
||||
return match.encoding
|
||||
|
||||
|
||||
def _get_candidate_encodings(raw: bytes) -> Iterator[str]:
|
||||
"""Yield candidate encodings lazily — expensive detection runs only if needed."""
|
||||
seen: set[str] = set()
|
||||
yield "utf-8"
|
||||
if (bom := _encodings_from_bom(raw)) and bom not in seen:
|
||||
yield bom
|
||||
if (
|
||||
locale_encoding := locale.getpreferredencoding(False)
|
||||
) and locale_encoding not in seen:
|
||||
yield locale_encoding
|
||||
if (best := _encoding_from_best_match(raw)) and best not in seen:
|
||||
yield best
|
||||
|
||||
|
||||
def decode_safe(raw: bytes, *, raise_on_error: bool = False) -> ReadSafeResult:
|
||||
"""Decode ``raw`` like :func:`read_safe` after ``read_bytes``.
|
||||
|
||||
Tries UTF-8, locale, BOM, charset-normalizer, then UTF-8 (strict or replace).
|
||||
``UnicodeDecodeError`` can only occur in that last step when
|
||||
``raise_on_error`` is true.
|
||||
"""
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
if raise_on_error:
|
||||
return path.read_text()
|
||||
return path.read_text(errors="replace")
|
||||
for encoding in _get_candidate_encodings(raw):
|
||||
try:
|
||||
return ReadSafeResult(raw.decode(encoding), encoding)
|
||||
except (LookupError, UnicodeDecodeError, ValueError):
|
||||
pass
|
||||
errors = "strict" if raise_on_error else "replace"
|
||||
return ReadSafeResult(raw.decode("utf-8", errors=errors), "utf-8")
|
||||
|
||||
|
||||
async def read_safe_async(path: Path, *, raise_on_error: bool = False) -> str:
|
||||
apath = anyio.Path(path)
|
||||
try:
|
||||
return await apath.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
if raise_on_error:
|
||||
return await apath.read_text()
|
||||
return await apath.read_text(errors="replace")
|
||||
def read_safe(path: Path, *, raise_on_error: bool = False) -> ReadSafeResult:
|
||||
"""Read ``path`` and decode with :func:`decode_safe`."""
|
||||
return decode_safe(path.read_bytes(), raise_on_error=raise_on_error)
|
||||
|
||||
|
||||
async def read_safe_async(
|
||||
path: Path, *, raise_on_error: bool = False
|
||||
) -> ReadSafeResult:
|
||||
"""Async :func:`read_safe` (``anyio``)."""
|
||||
raw = await anyio.Path(path).read_bytes()
|
||||
return decode_safe(raw, raise_on_error=raise_on_error)
|
||||
|
||||
@@ -26,6 +26,15 @@ def run_onboarding(app: App | None = None) -> None:
|
||||
case None:
|
||||
rprint("\n[yellow]Setup cancelled. See you next time![/]")
|
||||
sys.exit(0)
|
||||
case str() as s if s.startswith("env_var_error:"):
|
||||
env_key = s.removeprefix("env_var_error:")
|
||||
rprint(
|
||||
"\n[yellow]Could not save the API key because this provider is "
|
||||
f"configured with an invalid environment variable name: {env_key}.[/]"
|
||||
"\n[dim]The API key was not saved for this session. "
|
||||
"Update the provider's `api_key_env_var` setting in your config and try again.[/]\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
case str() as s if s.startswith("save_error:"):
|
||||
err = s.removeprefix("save_error:")
|
||||
rprint(
|
||||
|
||||
208
vibe/setup/onboarding/context.py
Normal file
208
vibe/setup/onboarding/context.py
Normal 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())
|
||||
@@ -13,11 +13,12 @@ from textual.widgets import Input, Link, Static
|
||||
|
||||
from vibe.cli.clipboard import copy_selection_to_clipboard
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.config import DEFAULT_PROVIDERS, ProviderConfig, VibeConfig
|
||||
from vibe.core.paths import GLOBAL_ENV_FILE
|
||||
from vibe.core.telemetry.send import TelemetryClient
|
||||
from vibe.core.types import Backend
|
||||
from vibe.setup.onboarding.base import OnboardingScreen
|
||||
from vibe.setup.onboarding.context import OnboardingContext
|
||||
|
||||
PROVIDER_HELP = {
|
||||
"mistral": ("https://console.mistral.ai/codestral/cli", "Mistral AI Studio")
|
||||
@@ -32,6 +33,42 @@ def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
|
||||
set_key(GLOBAL_ENV_FILE.path, env_key, api_key)
|
||||
|
||||
|
||||
def persist_api_key(provider: ProviderConfig, api_key: str) -> str:
|
||||
env_key = provider.api_key_env_var
|
||||
if not env_key:
|
||||
return "env_var_error:<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):
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("ctrl+c", "cancel", "Cancel", show=False),
|
||||
@@ -40,11 +77,9 @@ class ApiKeyScreen(OnboardingScreen):
|
||||
|
||||
NEXT_SCREEN = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, provider: ProviderConfig | None = None) -> None:
|
||||
super().__init__()
|
||||
config = VibeConfig.model_construct()
|
||||
active_model = config.get_active_model()
|
||||
self.provider = config.get_provider_for_model(active_model)
|
||||
self.provider = _resolve_onboarding_provider(provider)
|
||||
|
||||
def _compose_provider_link(self, provider_name: str) -> ComposeResult:
|
||||
if self.provider.name not in PROVIDER_HELP:
|
||||
@@ -124,20 +159,7 @@ class ApiKeyScreen(OnboardingScreen):
|
||||
self._save_and_finish(event.value)
|
||||
|
||||
def _save_and_finish(self, api_key: str) -> None:
|
||||
env_key = self.provider.api_key_env_var
|
||||
os.environ[env_key] = api_key
|
||||
try:
|
||||
_save_api_key_to_env_file(env_key, api_key)
|
||||
except OSError as err:
|
||||
self.app.exit(f"save_error:{err}")
|
||||
return
|
||||
if self.provider.backend == Backend.MISTRAL:
|
||||
try:
|
||||
telemetry = TelemetryClient(config_getter=VibeConfig)
|
||||
telemetry.send_onboarding_api_key_added()
|
||||
except Exception:
|
||||
pass # Telemetry is fire-and-forget; don't fail onboarding
|
||||
self.app.exit("completed")
|
||||
self.app.exit(persist_api_key(self.provider, api_key))
|
||||
|
||||
def on_mouse_up(self, event: MouseUp) -> None:
|
||||
copy_selection_to_clipboard(self.app)
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any, ClassVar
|
||||
from textual import events
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import CenterMiddle, Horizontal
|
||||
from textual.containers import Center, CenterMiddle, Horizontal, VerticalScroll
|
||||
from textual.message import Message
|
||||
from textual.widgets import Static
|
||||
|
||||
@@ -38,46 +38,77 @@ class TrustFolderDialog(CenterMiddle):
|
||||
class Untrusted(Message):
|
||||
pass
|
||||
|
||||
def __init__(self, folder_path: Path, **kwargs: Any) -> None:
|
||||
def __init__(
|
||||
self, folder_path: Path, detected_files: list[str], **kwargs: Any
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.folder_path = folder_path
|
||||
self.detected_files = detected_files
|
||||
self.selected_option = 0
|
||||
self.option_widgets: list[Static] = []
|
||||
|
||||
def _compose_scroll_content(self) -> ComposeResult:
|
||||
why_content = (
|
||||
"Files here can modify AI behavior. Malicious "
|
||||
"configs may exfiltrate data, run destructive "
|
||||
"commands, or silently alter your code."
|
||||
)
|
||||
with Center(classes="trust-dialog-section-center"):
|
||||
yield NoMarkupStatic(
|
||||
why_content,
|
||||
id="trust-dialog-warning",
|
||||
classes="trust-dialog-section-content",
|
||||
)
|
||||
|
||||
if self.detected_files:
|
||||
yield NoMarkupStatic(
|
||||
"Detected configuration files\n", classes="trust-dialog-section-header"
|
||||
)
|
||||
file_list = "\n".join(f"\u2022 {f}" for f in self.detected_files)
|
||||
with Center(classes="trust-dialog-section-center"):
|
||||
yield NoMarkupStatic(
|
||||
file_list,
|
||||
id="trust-dialog-files",
|
||||
classes="trust-dialog-section-content",
|
||||
)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with CenterMiddle(id="trust-dialog"):
|
||||
yield NoMarkupStatic(
|
||||
"⚠ Trust this folder and all its subfolders?", id="trust-dialog-title"
|
||||
)
|
||||
yield NoMarkupStatic(
|
||||
str(self.folder_path),
|
||||
id="trust-dialog-path",
|
||||
classes="trust-dialog-path",
|
||||
)
|
||||
yield NoMarkupStatic(
|
||||
"Files that can modify your Mistral Vibe setup were found here. Do you trust this folder and all its subfolders?",
|
||||
id="trust-dialog-message",
|
||||
classes="trust-dialog-message",
|
||||
)
|
||||
with CenterMiddle(id="trust-dialog-container"):
|
||||
with CenterMiddle(id="trust-dialog"):
|
||||
yield NoMarkupStatic("Trust this folder?", id="trust-dialog-title")
|
||||
yield NoMarkupStatic(
|
||||
str(self.folder_path),
|
||||
id="trust-dialog-path",
|
||||
classes="trust-dialog-path",
|
||||
)
|
||||
|
||||
with Horizontal(id="trust-options-container"):
|
||||
options = ["Yes", "No"]
|
||||
for idx, text in enumerate(options):
|
||||
widget = NoMarkupStatic(
|
||||
f" {idx + 1}. {text}", classes="trust-option"
|
||||
)
|
||||
self.option_widgets.append(widget)
|
||||
yield widget
|
||||
with VerticalScroll(id="trust-dialog-content"):
|
||||
yield from self._compose_scroll_content()
|
||||
|
||||
yield NoMarkupStatic(
|
||||
"← → navigate Enter select", classes="trust-dialog-help"
|
||||
)
|
||||
yield NoMarkupStatic(
|
||||
"Only trust folders you fully control",
|
||||
id="trust-dialog-footer-warning",
|
||||
classes="trust-dialog-footer-warning",
|
||||
)
|
||||
|
||||
yield NoMarkupStatic(
|
||||
f"Setting will be saved in: {TRUSTED_FOLDERS_FILE.path}",
|
||||
id="trust-dialog-save-info",
|
||||
classes="trust-dialog-save-info",
|
||||
)
|
||||
with Horizontal(id="trust-options-container"):
|
||||
options = ["Yes, trust this folder", "No, ignore config files"]
|
||||
for idx, text in enumerate(options):
|
||||
widget = NoMarkupStatic(
|
||||
f" {idx + 1}. {text}", classes="trust-option"
|
||||
)
|
||||
self.option_widgets.append(widget)
|
||||
yield widget
|
||||
|
||||
yield NoMarkupStatic(
|
||||
"← → navigate Enter select", classes="trust-dialog-help"
|
||||
)
|
||||
|
||||
yield NoMarkupStatic(
|
||||
f"Setting will be saved in: {TRUSTED_FOLDERS_FILE.path}",
|
||||
id="trust-dialog-save-info",
|
||||
classes="trust-dialog-save-info",
|
||||
)
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
self.selected_option = 1 # Default to "No"
|
||||
@@ -85,7 +116,7 @@ class TrustFolderDialog(CenterMiddle):
|
||||
self.focus()
|
||||
|
||||
def _update_options(self) -> None:
|
||||
options = ["Yes", "No"]
|
||||
options = ["Yes, trust this folder", "No, ignore config files"]
|
||||
|
||||
if len(self.option_widgets) != len(options):
|
||||
return
|
||||
@@ -146,9 +177,12 @@ class TrustFolderApp(App):
|
||||
Binding("ctrl+c", "quit_without_saving", "Quit", show=False, priority=True),
|
||||
]
|
||||
|
||||
def __init__(self, folder_path: Path, **kwargs: Any) -> None:
|
||||
def __init__(
|
||||
self, folder_path: Path, detected_files: list[str], **kwargs: Any
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.folder_path = folder_path
|
||||
self.detected_files = detected_files
|
||||
self._result: bool | None = None
|
||||
self._quit_without_saving = False
|
||||
|
||||
@@ -156,7 +190,7 @@ class TrustFolderApp(App):
|
||||
self.theme = "textual-ansi"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield TrustFolderDialog(self.folder_path)
|
||||
yield TrustFolderDialog(self.folder_path, self.detected_files)
|
||||
|
||||
def action_quit_without_saving(self) -> None:
|
||||
self._quit_without_saving = True
|
||||
@@ -177,6 +211,6 @@ class TrustFolderApp(App):
|
||||
return self._result
|
||||
|
||||
|
||||
def ask_trust_folder(folder_path: Path) -> bool | None:
|
||||
app = TrustFolderApp(folder_path)
|
||||
def ask_trust_folder(folder_path: Path, detected_files: list[str]) -> bool | None:
|
||||
app = TrustFolderApp(folder_path, detected_files)
|
||||
return app.run_trust_dialog()
|
||||
|
||||
@@ -3,14 +3,25 @@ Screen {
|
||||
background: transparent 80%;
|
||||
}
|
||||
|
||||
|
||||
#trust-dialog {
|
||||
width: 70;
|
||||
max-width: 90%;
|
||||
min-width: 50;
|
||||
height: auto;
|
||||
max-width: 70;
|
||||
overflow-y: scroll;
|
||||
border: round ansi_bright_black;
|
||||
background: transparent;
|
||||
height:auto;
|
||||
max-height: 1fr;
|
||||
padding: 1;
|
||||
padding-left:5;
|
||||
padding-right:5;
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
|
||||
#trust-dialog-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 3;
|
||||
max-height: 10;
|
||||
}
|
||||
|
||||
#trust-dialog-title {
|
||||
@@ -31,15 +42,42 @@ Screen {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#trust-dialog-message {
|
||||
.trust-dialog-section-header {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
text-align: center;
|
||||
text-wrap: wrap;
|
||||
padding: 0 2;
|
||||
}
|
||||
|
||||
.trust-dialog-section-center {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.trust-dialog-section-content {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
text-align: center;
|
||||
text-overflow: fold;
|
||||
}
|
||||
|
||||
.trust-dialog-footer-warning {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_yellow;
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
border-top: solid ansi_bright_black;
|
||||
border-bottom: solid ansi_bright_black;
|
||||
margin-bottom: 1;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#trust-options-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
@@ -79,5 +117,4 @@ Screen {
|
||||
text-align: center;
|
||||
text-style: italic;
|
||||
text-wrap: wrap;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# What's new in v2.7.4
|
||||
- **MCP command**: New `/mcp` command to display MCP servers and their status
|
||||
- **Manual command output**: Manual `!` commands now forward their output to the agent as context
|
||||
- **Console view**: New `ctrl+\` keybind to toggle logs display.
|
||||
# What's new in v2.7.5
|
||||
|
||||
- **Trust dialog**: Display detected files and LLM risks in the trust folder dialog
|
||||
- **Faster startup**: Deferred MCP and git I/O to a background thread for faster CLI startup
|
||||
|
||||
Reference in New Issue
Block a user