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