v1.1.3
Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai> Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai> Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai>
This commit is contained in:
parent
a340a721ea
commit
661588de0c
48 changed files with 1679 additions and 467 deletions
65
.github/workflows/build-and-upload.yml
vendored
65
.github/workflows/build-and-upload.yml
vendored
|
|
@ -8,37 +8,48 @@ on:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
configure:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||||
|
steps:
|
||||||
|
- name: Set matrix
|
||||||
|
id: set-matrix
|
||||||
|
run: |
|
||||||
|
if [[ "${{ github.repository }}" == "mistralai/mistral-vibe" ]]; then
|
||||||
|
matrix='{
|
||||||
|
"include": [
|
||||||
|
{"runner": "ubuntu-22.04", "os": "linux", "arch": "x86_64"},
|
||||||
|
{"runner": "ubuntu-22.04-arm", "os": "linux", "arch": "aarch64"},
|
||||||
|
{"runner": "macos-15-intel", "os": "darwin", "arch": "x86_64"},
|
||||||
|
{"runner": "macos-14", "os": "darwin", "arch": "aarch64"},
|
||||||
|
{"runner": "windows-2022", "os": "windows", "arch": "x86_64"},
|
||||||
|
{"runner": "windows-11-arm", "os": "windows", "arch": "aarch64"}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
else # skip ARM Linux/Windows (runners not available on non public repos)
|
||||||
|
matrix='{
|
||||||
|
"include": [
|
||||||
|
{"runner": "ubuntu-22.04", "os": "linux", "arch": "x86_64"},
|
||||||
|
{"runner": "macos-15-intel", "os": "darwin", "arch": "x86_64"},
|
||||||
|
{"runner": "macos-14", "os": "darwin", "arch": "aarch64"},
|
||||||
|
{"runner": "windows-2022", "os": "windows", "arch": "x86_64"}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
fi
|
||||||
|
echo "matrix=$(echo $matrix | jq -c .)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
build-and-upload:
|
build-and-upload:
|
||||||
|
needs: configure
|
||||||
name: ${{ matrix.os }}-${{ matrix.arch }}
|
name: ${{ matrix.os }}-${{ matrix.arch }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix: ${{ fromJSON(needs.configure.outputs.matrix) }}
|
||||||
include:
|
|
||||||
# Linux
|
|
||||||
- runner: ubuntu-22.04
|
|
||||||
os: linux
|
|
||||||
arch: x86_64
|
|
||||||
# - runner: ubuntu-22.04-arm # ubuntu-22.04-arm, ubuntu-24.04-arm and windows-11-arm are not supported yet for private repositories
|
|
||||||
# os: linux
|
|
||||||
# arch: aarch64
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
- runner: macos-15-intel
|
|
||||||
os: darwin
|
|
||||||
arch: x86_64
|
|
||||||
- runner: macos-14
|
|
||||||
os: darwin
|
|
||||||
arch: aarch64
|
|
||||||
|
|
||||||
# Windows
|
|
||||||
- runner: windows-2022
|
|
||||||
os: windows
|
|
||||||
arch: x86_64
|
|
||||||
# - runner: windows-11-arm # ubuntu-22.04-arm, ubuntu-24.04-arm and windows-11-arm are not supported yet for private repositories
|
|
||||||
# os: windows
|
|
||||||
# arch: aarch64
|
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: echo
|
||||||
|
run: echo github.repository=${{ github.repository }}
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||||
|
|
||||||
|
|
@ -55,10 +66,10 @@ jobs:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Sync dependencies
|
- name: Sync dependencies
|
||||||
run: uv sync --all-extras
|
run: uv sync --no-dev --group build
|
||||||
|
|
||||||
- name: Build with PyInstaller
|
- name: Build with PyInstaller
|
||||||
run: uv run pyinstaller vibe-acp.spec
|
run: uv run --no-dev --group build pyinstaller vibe-acp.spec
|
||||||
|
|
||||||
- name: Get package version with uv (Unix)
|
- name: Get package version with uv (Unix)
|
||||||
id: get_version_unix
|
id: get_version_unix
|
||||||
|
|
|
||||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "ACP Server",
|
"name": "ACP Server",
|
||||||
|
|
|
||||||
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -5,6 +5,27 @@ 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).
|
||||||
|
|
||||||
|
## [1.1.3] - 2025-12-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add more copy_to_clipboard methods to support all cases
|
||||||
|
- Add bindings to scroll chat history
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Relax config to accept extra inputs
|
||||||
|
- Remove useless stats from assistant events
|
||||||
|
- Improve scroll actions while streaming
|
||||||
|
- Do not check for updates more than once a day
|
||||||
|
- Use PyPI in update notifier
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix tool permission handling for "allow always" option in ACP
|
||||||
|
- Fix security issue: prevent command injection in GitHub Action prompt handling
|
||||||
|
- Fix issues with vLLM
|
||||||
|
|
||||||
## [1.1.2] - 2025-12-11
|
## [1.1.2] - 2025-12-11
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -52,13 +52,9 @@ runs:
|
||||||
working-directory: ${{ github.action_path }}
|
working-directory: ${{ github.action_path }}
|
||||||
env:
|
env:
|
||||||
MISTRAL_API_KEY: "${{ inputs.MISTRAL_API_KEY }}"
|
MISTRAL_API_KEY: "${{ inputs.MISTRAL_API_KEY }}"
|
||||||
|
PROMPT_INPUT: "${{ inputs.prompt }}"
|
||||||
run: |
|
run: |
|
||||||
# We want to make sure that any text passed in here
|
|
||||||
# doesn't have special bash characters (<, >, &, etc...)
|
|
||||||
ESCAPED_PROMPT=$(printf '%q' "${{ inputs.prompt }}")
|
|
||||||
|
|
||||||
# Change back to the original working directory for the tool to work
|
|
||||||
cd "${{ github.workspace }}"
|
cd "${{ github.workspace }}"
|
||||||
uv run --directory "${{ github.action_path }}" vibe \
|
uv run --directory "${{ github.action_path }}" vibe \
|
||||||
--auto-approve \
|
--auto-approve \
|
||||||
-p "${ESCAPED_PROMPT}"
|
-p "$PROMPT_INPUT"
|
||||||
|
|
|
||||||
|
|
@ -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 = "1.1.2"
|
version = "1.1.3"
|
||||||
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/v1.1.2/vibe-acp-darwin-aarch64-1.1.2.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.3/vibe-acp-darwin-aarch64-1.1.3.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/v1.1.2/vibe-acp-darwin-x86_64-1.1.2.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.3/vibe-acp-darwin-x86_64-1.1.3.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/v1.1.2/vibe-acp-linux-aarch64-1.1.2.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.1/vibe-acp-linux-aarch64-1.1.3.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/v1.1.2/vibe-acp-linux-x86_64-1.1.2.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.3/vibe-acp-linux-x86_64-1.1.3.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/v1.1.2/vibe-acp-windows-aarch64-1.1.2.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.1/vibe-acp-windows-aarch64-1.1.3.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/v1.1.2/vibe-acp-windows-x86_64-1.1.2.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.3/vibe-acp-windows-x86_64-1.1.3.zip"
|
||||||
cmd = "./vibe-acp.exe"
|
cmd = "./vibe-acp.exe"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "1.1.2"
|
version = "1.1.3"
|
||||||
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"
|
||||||
|
|
@ -86,10 +86,11 @@ dev = [
|
||||||
"twine>=5.0.0",
|
"twine>=5.0.0",
|
||||||
"typos>=1.34.0",
|
"typos>=1.34.0",
|
||||||
"vulture>=2.14",
|
"vulture>=2.14",
|
||||||
"pyinstaller>=6.17.0",
|
|
||||||
"pytest-xdist>=3.8.0",
|
"pytest-xdist>=3.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
build = ["pyinstaller>=6.17.0"]
|
||||||
|
|
||||||
[tool.pyright]
|
[tool.pyright]
|
||||||
pythonVersion = "3.12"
|
pythonVersion = "3.12"
|
||||||
reportMissingTypeStubs = false
|
reportMissingTypeStubs = false
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import asyncio
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from acp import (
|
from acp import (
|
||||||
|
|
@ -27,8 +28,10 @@ from acp.schema import (
|
||||||
)
|
)
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import pytest
|
import pytest
|
||||||
|
import tomli_w
|
||||||
|
|
||||||
from tests import TESTS_ROOT
|
from tests import TESTS_ROOT
|
||||||
|
from tests.conftest import get_base_config
|
||||||
from tests.mock.utils import get_mocking_env, mock_llm_chunk
|
from tests.mock.utils import get_mocking_env, mock_llm_chunk
|
||||||
from vibe.acp.utils import ToolOption
|
from vibe.acp.utils import ToolOption
|
||||||
from vibe.core.types import FunctionCall, ToolCall
|
from vibe.core.types import FunctionCall, ToolCall
|
||||||
|
|
@ -38,6 +41,63 @@ MOCK_ENTRYPOINT_PATH = "tests/mock/mock_entrypoint.py"
|
||||||
PLAYGROUND_DIR = TESTS_ROOT / "playground"
|
PLAYGROUND_DIR = TESTS_ROOT / "playground"
|
||||||
|
|
||||||
|
|
||||||
|
def deep_merge(target: dict, source: dict) -> None:
|
||||||
|
for key, value in source.items():
|
||||||
|
if (
|
||||||
|
key in target
|
||||||
|
and isinstance(target.get(key), dict)
|
||||||
|
and isinstance(value, dict)
|
||||||
|
):
|
||||||
|
deep_merge(target[key], value)
|
||||||
|
elif (
|
||||||
|
key in target
|
||||||
|
and isinstance(target.get(key), list)
|
||||||
|
and isinstance(value, list)
|
||||||
|
):
|
||||||
|
if key in {"providers", "models"}:
|
||||||
|
target[key] = value
|
||||||
|
else:
|
||||||
|
target[key] = list(set(value + target[key]))
|
||||||
|
else:
|
||||||
|
target[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def _create_vibe_home_dir(tmp_path: Path, *sections: dict[str, Any]) -> Path:
|
||||||
|
"""Create a temporary vibe home directory with a minimal config file."""
|
||||||
|
vibe_home = tmp_path / ".vibe"
|
||||||
|
vibe_home.mkdir()
|
||||||
|
|
||||||
|
config_file = vibe_home / "config.toml"
|
||||||
|
base_config_dict = get_base_config()
|
||||||
|
|
||||||
|
base_config_dict["active_model"] = "devstral-latest"
|
||||||
|
if base_config_dict.get("models"):
|
||||||
|
for model in base_config_dict["models"]:
|
||||||
|
if model.get("name") == "mistral-vibe-cli-latest":
|
||||||
|
model["alias"] = "devstral-latest"
|
||||||
|
|
||||||
|
if sections:
|
||||||
|
for section_dict in sections:
|
||||||
|
deep_merge(base_config_dict, section_dict)
|
||||||
|
|
||||||
|
with config_file.open("wb") as f:
|
||||||
|
tomli_w.dump(base_config_dict, f)
|
||||||
|
|
||||||
|
return vibe_home
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def vibe_home_dir(tmp_path: Path) -> Path:
|
||||||
|
"""Create a temporary vibe home directory with a minimal config file."""
|
||||||
|
return _create_vibe_home_dir(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def vibe_home_grep_ask(tmp_path: Path) -> Path:
|
||||||
|
"""Create a temporary vibe home directory with grep configured to ask permission."""
|
||||||
|
return _create_vibe_home_dir(tmp_path, {"tools": {"grep": {"permission": "ask"}}})
|
||||||
|
|
||||||
|
|
||||||
class JsonRpcRequest(BaseModel):
|
class JsonRpcRequest(BaseModel):
|
||||||
jsonrpc: str = "2.0"
|
jsonrpc: str = "2.0"
|
||||||
id: int | str
|
id: int | str
|
||||||
|
|
@ -127,10 +187,15 @@ class WriteTextFileJsonRpcResponse(JsonRpcResponse):
|
||||||
|
|
||||||
|
|
||||||
async def get_acp_agent_process(
|
async def get_acp_agent_process(
|
||||||
mock: bool = True, mock_env: dict[str, str] | None = None
|
mock_env: dict[str, str], vibe_home: Path
|
||||||
) -> AsyncGenerator[asyncio.subprocess.Process]:
|
) -> AsyncGenerator[asyncio.subprocess.Process]:
|
||||||
current_env = os.environ.copy()
|
current_env = os.environ.copy()
|
||||||
cmd = ["uv", "run", MOCK_ENTRYPOINT_PATH if mock else "vibe-acp"]
|
cmd = ["uv", "run", MOCK_ENTRYPOINT_PATH]
|
||||||
|
|
||||||
|
env = dict(current_env)
|
||||||
|
env.update(mock_env)
|
||||||
|
env["MISTRAL_API_KEY"] = "mock"
|
||||||
|
env["VIBE_HOME"] = str(vibe_home)
|
||||||
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
|
|
@ -138,11 +203,7 @@ async def get_acp_agent_process(
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
cwd=TESTS_ROOT.parent,
|
cwd=TESTS_ROOT.parent,
|
||||||
env={
|
env=env,
|
||||||
**current_env,
|
|
||||||
**(mock_env or {}),
|
|
||||||
**({"MISTRAL_API_KEY": "mock"} if mock else {}),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -322,9 +383,11 @@ async def initialize_session(acp_agent_process: asyncio.subprocess.Process) -> s
|
||||||
|
|
||||||
class TestSessionManagement:
|
class TestSessionManagement:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_multiple_sessions_unique_ids(self) -> None:
|
async def test_multiple_sessions_unique_ids(self, vibe_home_dir: Path) -> None:
|
||||||
mock_env = get_mocking_env(mock_chunks=[mock_llm_chunk() for _ in range(3)])
|
mock_env = get_mocking_env(mock_chunks=[mock_llm_chunk() for _ in range(3)])
|
||||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
async for process in get_acp_agent_process(
|
||||||
|
mock_env=mock_env, vibe_home=vibe_home_dir
|
||||||
|
):
|
||||||
await send_json_rpc(
|
await send_json_rpc(
|
||||||
process,
|
process,
|
||||||
InitializeJsonRpcRequest(
|
InitializeJsonRpcRequest(
|
||||||
|
|
@ -359,9 +422,11 @@ class TestSessionManagement:
|
||||||
|
|
||||||
class TestSessionUpdates:
|
class TestSessionUpdates:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_agent_message_chunk_structure(self) -> None:
|
async def test_agent_message_chunk_structure(self, vibe_home_dir: Path) -> None:
|
||||||
mock_env = get_mocking_env([mock_llm_chunk(content="Hi") for _ in range(2)])
|
mock_env = get_mocking_env([mock_llm_chunk(content="Hi") for _ in range(2)])
|
||||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
async for process in get_acp_agent_process(
|
||||||
|
mock_env=mock_env, vibe_home=vibe_home_dir
|
||||||
|
):
|
||||||
# Check stderr for error details if process failed
|
# Check stderr for error details if process failed
|
||||||
if process.returncode is not None and process.stderr:
|
if process.returncode is not None and process.stderr:
|
||||||
stderr_data = await process.stderr.read()
|
stderr_data = await process.stderr.read()
|
||||||
|
|
@ -395,7 +460,7 @@ class TestSessionUpdates:
|
||||||
assert response.params.update.content.text == "Hi"
|
assert response.params.update.content.text == "Hi"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tool_call_update_structure(self) -> None:
|
async def test_tool_call_update_structure(self, vibe_home_dir: Path) -> None:
|
||||||
mock_env = get_mocking_env([
|
mock_env = get_mocking_env([
|
||||||
mock_llm_chunk(content="Hey"),
|
mock_llm_chunk(content="Hey"),
|
||||||
mock_llm_chunk(
|
mock_llm_chunk(
|
||||||
|
|
@ -416,7 +481,9 @@ class TestSessionUpdates:
|
||||||
finish_reason="stop",
|
finish_reason="stop",
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
async for process in get_acp_agent_process(
|
||||||
|
mock_env=mock_env, vibe_home=vibe_home_dir
|
||||||
|
):
|
||||||
session_id = await initialize_session(process)
|
session_id = await initialize_session(process)
|
||||||
|
|
||||||
await send_json_rpc(
|
await send_json_rpc(
|
||||||
|
|
@ -491,32 +558,32 @@ async def start_session_with_request_permission(
|
||||||
return last_response
|
return last_response
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(
|
|
||||||
reason="Disabled until we have a way to properly mock the fs and acp interactions"
|
|
||||||
)
|
|
||||||
class TestToolCallStructure:
|
class TestToolCallStructure:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tool_call_request_permission_structure(self) -> None:
|
async def test_tool_call_request_permission_structure(
|
||||||
|
self, vibe_home_grep_ask: Path
|
||||||
|
) -> None:
|
||||||
custom_results = [
|
custom_results = [
|
||||||
mock_llm_chunk(content="Hey"),
|
mock_llm_chunk(content="Hey"),
|
||||||
mock_llm_chunk(
|
mock_llm_chunk(
|
||||||
tool_calls=[
|
tool_calls=[
|
||||||
ToolCall(
|
ToolCall(
|
||||||
function=FunctionCall(
|
function=FunctionCall(
|
||||||
name="write_file",
|
name="grep",
|
||||||
arguments='{"path":"test.txt","content":"hello, world!"'
|
arguments='{"pattern":"auth","path":".","max_matches":null,"use_default_ignore":true}',
|
||||||
',"overwrite":true}',
|
|
||||||
),
|
),
|
||||||
type="function",
|
type="function",
|
||||||
index=0,
|
index=0,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
name="write_file",
|
name="grep",
|
||||||
finish_reason="stop",
|
finish_reason="tool_calls",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
mock_env = get_mocking_env(custom_results)
|
mock_env = get_mocking_env(custom_results)
|
||||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
async for process in get_acp_agent_process(
|
||||||
|
mock_env=mock_env, vibe_home=vibe_home_grep_ask
|
||||||
|
):
|
||||||
session_id = await initialize_session(process)
|
session_id = await initialize_session(process)
|
||||||
await send_json_rpc(
|
await send_json_rpc(
|
||||||
process,
|
process,
|
||||||
|
|
@ -527,14 +594,15 @@ class TestToolCallStructure:
|
||||||
prompt=[
|
prompt=[
|
||||||
TextContentBlock(
|
TextContentBlock(
|
||||||
type="text",
|
type="text",
|
||||||
text="Create a new file named test.txt "
|
text="Search for files containing the pattern 'auth'",
|
||||||
"with content 'hello, world!'",
|
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
text_responses = await read_multiple_responses(process, max_count=3)
|
text_responses = await read_multiple_responses(
|
||||||
|
process, max_count=15, timeout_per_response=2.0
|
||||||
|
)
|
||||||
responses = parse_conversation(text_responses)
|
responses = parse_conversation(text_responses)
|
||||||
|
|
||||||
# Look for tool call request permission updates
|
# Look for tool call request permission updates
|
||||||
|
|
@ -543,7 +611,8 @@ class TestToolCallStructure:
|
||||||
]
|
]
|
||||||
|
|
||||||
assert len(permission_requests) > 0, (
|
assert len(permission_requests) > 0, (
|
||||||
"No tool call permission requests found"
|
f"No tool call permission requests found. Got {len(responses)} responses: "
|
||||||
|
f"{[type(r).__name__ for r in responses]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
first_request = permission_requests[0]
|
first_request = permission_requests[0]
|
||||||
|
|
@ -552,32 +621,35 @@ class TestToolCallStructure:
|
||||||
assert first_request.params.toolCall.toolCallId is not None
|
assert first_request.params.toolCall.toolCallId is not None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tool_call_update_approved_structure(self) -> None:
|
async def test_tool_call_update_approved_structure(
|
||||||
|
self, vibe_home_grep_ask: Path
|
||||||
|
) -> None:
|
||||||
custom_results = [
|
custom_results = [
|
||||||
mock_llm_chunk(content="Hey"),
|
mock_llm_chunk(content="Hey"),
|
||||||
mock_llm_chunk(
|
mock_llm_chunk(
|
||||||
tool_calls=[
|
tool_calls=[
|
||||||
ToolCall(
|
ToolCall(
|
||||||
function=FunctionCall(
|
function=FunctionCall(
|
||||||
name="write_file",
|
name="grep",
|
||||||
arguments='{"path":"test.txt","content":"hello, world!"'
|
arguments='{"pattern":"auth","path":".","max_matches":null,"use_default_ignore":true}',
|
||||||
',"overwrite":true}',
|
|
||||||
),
|
),
|
||||||
type="function",
|
type="function",
|
||||||
index=0,
|
index=0,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
name="write_file",
|
name="grep",
|
||||||
finish_reason="tool_calls",
|
finish_reason="tool_calls",
|
||||||
),
|
),
|
||||||
mock_llm_chunk(
|
mock_llm_chunk(
|
||||||
content="The file test.txt has been created", finish_reason="stop"
|
content="The search for 'auth' has been completed", finish_reason="stop"
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
mock_env = get_mocking_env(custom_results)
|
mock_env = get_mocking_env(custom_results)
|
||||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
async for process in get_acp_agent_process(
|
||||||
|
mock_env=mock_env, vibe_home=vibe_home_grep_ask
|
||||||
|
):
|
||||||
permission_request = await start_session_with_request_permission(
|
permission_request = await start_session_with_request_permission(
|
||||||
process, "Create a file named test.txt"
|
process, "Search for files containing the pattern 'auth'"
|
||||||
)
|
)
|
||||||
assert permission_request.params is not None
|
assert permission_request.params is not None
|
||||||
selected_option_id = ToolOption.ALLOW_ONCE
|
selected_option_id = ToolOption.ALLOW_ONCE
|
||||||
|
|
@ -613,34 +685,37 @@ class TestToolCallStructure:
|
||||||
assert approved_tool_call is not None
|
assert approved_tool_call is not None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tool_call_update_rejected_structure(self) -> None:
|
async def test_tool_call_update_rejected_structure(
|
||||||
|
self, vibe_home_grep_ask: Path
|
||||||
|
) -> None:
|
||||||
custom_results = [
|
custom_results = [
|
||||||
mock_llm_chunk(content="Hey"),
|
mock_llm_chunk(content="Hey"),
|
||||||
mock_llm_chunk(
|
mock_llm_chunk(
|
||||||
tool_calls=[
|
tool_calls=[
|
||||||
ToolCall(
|
ToolCall(
|
||||||
function=FunctionCall(
|
function=FunctionCall(
|
||||||
name="write_file",
|
name="grep",
|
||||||
arguments='{"path":"test.txt","content":"hello, world!"'
|
arguments='{"pattern":"auth","path":".","max_matches":null,"use_default_ignore":true}',
|
||||||
',"overwrite":false}',
|
|
||||||
),
|
),
|
||||||
type="function",
|
type="function",
|
||||||
index=0,
|
index=0,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
name="write_file",
|
name="grep",
|
||||||
finish_reason="tool_calls",
|
finish_reason="tool_calls",
|
||||||
),
|
),
|
||||||
mock_llm_chunk(
|
mock_llm_chunk(
|
||||||
content="The file test.txt has not been created, "
|
content="The search for 'auth' has not been performed, "
|
||||||
"because you rejected the permission request",
|
"because you rejected the permission request",
|
||||||
finish_reason="stop",
|
finish_reason="stop",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
mock_env = get_mocking_env(custom_results)
|
mock_env = get_mocking_env(custom_results)
|
||||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
async for process in get_acp_agent_process(
|
||||||
|
mock_env=mock_env, vibe_home=vibe_home_grep_ask
|
||||||
|
):
|
||||||
permission_request = await start_session_with_request_permission(
|
permission_request = await start_session_with_request_permission(
|
||||||
process, "Create a file named test.txt"
|
process, "Search for files containing the pattern 'auth'"
|
||||||
)
|
)
|
||||||
assert permission_request.params is not None
|
assert permission_request.params is not None
|
||||||
selected_option_id = ToolOption.REJECT_ONCE
|
selected_option_id = ToolOption.REJECT_ONCE
|
||||||
|
|
@ -677,28 +752,33 @@ class TestToolCallStructure:
|
||||||
|
|
||||||
@pytest.mark.skip(reason="Long running tool call updates are not implemented yet")
|
@pytest.mark.skip(reason="Long running tool call updates are not implemented yet")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tool_call_in_progress_update_structure(self) -> None:
|
async def test_tool_call_in_progress_update_structure(
|
||||||
|
self, vibe_home_grep_ask: Path
|
||||||
|
) -> None:
|
||||||
custom_results = [
|
custom_results = [
|
||||||
mock_llm_chunk(content="Hey"),
|
mock_llm_chunk(content="Hey"),
|
||||||
mock_llm_chunk(
|
mock_llm_chunk(
|
||||||
tool_calls=[
|
tool_calls=[
|
||||||
ToolCall(
|
ToolCall(
|
||||||
function=FunctionCall(
|
function=FunctionCall(
|
||||||
name="bash",
|
name="grep",
|
||||||
arguments='{"command":"sleep 3","timeout":null}',
|
arguments='{"pattern":"auth","path":".","max_matches":null,"use_default_ignore":true}',
|
||||||
),
|
),
|
||||||
type="function",
|
type="function",
|
||||||
|
index=0,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
name="bash",
|
name="grep",
|
||||||
finish_reason="tool_calls",
|
finish_reason="tool_calls",
|
||||||
),
|
),
|
||||||
mock_llm_chunk(
|
mock_llm_chunk(
|
||||||
content="The command sleep 3 has been run", finish_reason="stop"
|
content="The search for 'auth' has been completed", finish_reason="stop"
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
mock_env = get_mocking_env(custom_results)
|
mock_env = get_mocking_env(custom_results)
|
||||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
async for process in get_acp_agent_process(
|
||||||
|
mock_env=mock_env, vibe_home=vibe_home_grep_ask
|
||||||
|
):
|
||||||
session_id = await initialize_session(process)
|
session_id = await initialize_session(process)
|
||||||
await send_json_rpc(
|
await send_json_rpc(
|
||||||
process,
|
process,
|
||||||
|
|
@ -708,7 +788,8 @@ class TestToolCallStructure:
|
||||||
sessionId=session_id,
|
sessionId=session_id,
|
||||||
prompt=[
|
prompt=[
|
||||||
TextContentBlock(
|
TextContentBlock(
|
||||||
type="text", text="Run sleep 3 in the current directory"
|
type="text",
|
||||||
|
text="Search for files containing the pattern 'auth'",
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -732,34 +813,38 @@ class TestToolCallStructure:
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tool_call_result_update_failure_structure(self) -> None:
|
async def test_tool_call_result_update_failure_structure(
|
||||||
|
self, vibe_home_grep_ask: Path
|
||||||
|
) -> None:
|
||||||
custom_results = [
|
custom_results = [
|
||||||
mock_llm_chunk(content="Hey"),
|
mock_llm_chunk(content="Hey"),
|
||||||
mock_llm_chunk(
|
mock_llm_chunk(
|
||||||
tool_calls=[
|
tool_calls=[
|
||||||
ToolCall(
|
ToolCall(
|
||||||
function=FunctionCall(
|
function=FunctionCall(
|
||||||
name="write_file",
|
name="grep",
|
||||||
arguments='{"path":"/test.txt","content":"hello, world!"'
|
arguments='{"pattern":"auth","path":"/nonexistent","max_matches":null,"use_default_ignore":true}',
|
||||||
',"overwrite":true}',
|
|
||||||
),
|
),
|
||||||
type="function",
|
type="function",
|
||||||
index=0,
|
index=0,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
name="write_file",
|
name="grep",
|
||||||
finish_reason="tool_calls",
|
finish_reason="tool_calls",
|
||||||
),
|
),
|
||||||
mock_llm_chunk(
|
mock_llm_chunk(
|
||||||
content="The file /test.txt has not been created "
|
content="The search for 'auth' has failed "
|
||||||
"because it's outside the project directory",
|
"because the path does not exist",
|
||||||
finish_reason="stop",
|
finish_reason="stop",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
mock_env = get_mocking_env(custom_results)
|
mock_env = get_mocking_env(custom_results)
|
||||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
async for process in get_acp_agent_process(
|
||||||
|
mock_env=mock_env, vibe_home=vibe_home_grep_ask
|
||||||
|
):
|
||||||
permission_request = await start_session_with_request_permission(
|
permission_request = await start_session_with_request_permission(
|
||||||
process, "Create a file named /test.txt"
|
process,
|
||||||
|
"Search for files containing the pattern 'auth' in /nonexistent",
|
||||||
)
|
)
|
||||||
assert permission_request.params is not None
|
assert permission_request.params is not None
|
||||||
selected_option_id = ToolOption.ALLOW_ONCE
|
selected_option_id = ToolOption.ALLOW_ONCE
|
||||||
|
|
@ -802,7 +887,9 @@ class TestCancellationStructure:
|
||||||
"(and not only at tool call time)"
|
"(and not only at tool call time)"
|
||||||
)
|
)
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tool_call_update_cancelled_structure(self) -> None:
|
async def test_tool_call_update_cancelled_structure(
|
||||||
|
self, vibe_home_dir: Path
|
||||||
|
) -> None:
|
||||||
custom_results = [
|
custom_results = [
|
||||||
mock_llm_chunk(content="Hey"),
|
mock_llm_chunk(content="Hey"),
|
||||||
mock_llm_chunk(
|
mock_llm_chunk(
|
||||||
|
|
@ -827,7 +914,9 @@ class TestCancellationStructure:
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
mock_env = get_mocking_env(custom_results)
|
mock_env = get_mocking_env(custom_results)
|
||||||
async for process in get_acp_agent_process(mock_env=mock_env):
|
async for process in get_acp_agent_process(
|
||||||
|
mock_env=mock_env, vibe_home=vibe_home_dir
|
||||||
|
):
|
||||||
permission_request = await start_session_with_request_permission(
|
permission_request = await start_session_with_request_permission(
|
||||||
process, "Create a file named test.txt"
|
process, "Create a file named test.txt"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ class TestACPInitialize:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert response.agentInfo == Implementation(
|
assert response.agentInfo == Implementation(
|
||||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.1.2"
|
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.1.3"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.authMethods == []
|
assert response.authMethods == []
|
||||||
|
|
@ -63,7 +63,7 @@ class TestACPInitialize:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert response.agentInfo == Implementation(
|
assert response.agentInfo == Implementation(
|
||||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.1.2"
|
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.1.3"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.authMethods is not None
|
assert response.authMethods is not None
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
VibeConfig.dump_config(config.model_dump())
|
||||||
|
|
||||||
class PatchedAgent(Agent):
|
class PatchedAgent(Agent):
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
super().__init__(*args, **{**kwargs, "backend": backend})
|
super().__init__(*args, **{**kwargs, "backend": backend})
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ the tests will be. Always prefer real API data over manually constructed example
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import respx
|
import respx
|
||||||
|
|
@ -249,6 +251,58 @@ class TestBackend:
|
||||||
assert e.value.reason == response.reason_phrase
|
assert e.value.reason == response.reason_phrase
|
||||||
assert e.value.parsed_error is None
|
assert e.value.parsed_error is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"base_url,provider_name,expected_stream_options",
|
||||||
|
[
|
||||||
|
("https://api.fireworks.ai", "fireworks", {"include_usage": True}),
|
||||||
|
(
|
||||||
|
"https://api.mistral.ai",
|
||||||
|
"mistral",
|
||||||
|
{"include_usage": True, "stream_tool_calls": True},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_backend_streaming_payload_includes_stream_options(
|
||||||
|
self, base_url: Url, provider_name: str, expected_stream_options: dict
|
||||||
|
):
|
||||||
|
with respx.mock(base_url=base_url) as mock_api:
|
||||||
|
route = mock_api.post("/v1/chat/completions").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
status_code=200,
|
||||||
|
stream=httpx.ByteStream(
|
||||||
|
b'data: {"choices": [{"delta": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 10, "completion_tokens": 5}}\n\ndata: [DONE]\n\n'
|
||||||
|
),
|
||||||
|
headers={"Content-Type": "text/event-stream"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider = ProviderConfig(
|
||||||
|
name=provider_name, api_base=f"{base_url}/v1", api_key_env_var="API_KEY"
|
||||||
|
)
|
||||||
|
backend = GenericBackend(provider=provider)
|
||||||
|
model = ModelConfig(
|
||||||
|
name="model_name", provider=provider_name, alias="model_alias"
|
||||||
|
)
|
||||||
|
messages = [LLMMessage(role=Role.user, content="hi")]
|
||||||
|
|
||||||
|
async for _ in backend.complete_streaming(
|
||||||
|
model=model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=0.2,
|
||||||
|
tools=None,
|
||||||
|
max_tokens=None,
|
||||||
|
tool_choice=None,
|
||||||
|
extra_headers=None,
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert route.called
|
||||||
|
request = route.calls.last.request
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
|
||||||
|
assert payload["stream"] is True
|
||||||
|
assert payload["stream_options"] == expected_stream_options
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("backend_type", [Backend.MISTRAL, Backend.GENERIC])
|
@pytest.mark.parametrize("backend_type", [Backend.MISTRAL, Backend.GENERIC])
|
||||||
async def test_backend_user_agent(self, backend_type: Backend):
|
async def test_backend_user_agent(self, backend_type: Backend):
|
||||||
|
|
|
||||||
228
tests/cli/test_clipboard.py
Normal file
228
tests/cli/test_clipboard.py
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import cast
|
||||||
|
from unittest.mock import MagicMock, mock_open, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from textual.app import App
|
||||||
|
|
||||||
|
from vibe.cli.clipboard import _copy_osc52, copy_selection_to_clipboard
|
||||||
|
|
||||||
|
|
||||||
|
class MockWidget:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
text_selection: object | None = None,
|
||||||
|
get_selection_result: tuple[str, object] | None = None,
|
||||||
|
get_selection_raises: Exception | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.text_selection = text_selection
|
||||||
|
self._get_selection_result = get_selection_result
|
||||||
|
self._get_selection_raises = get_selection_raises
|
||||||
|
|
||||||
|
def get_selection(self, selection: object) -> tuple[str, object]:
|
||||||
|
if self._get_selection_raises:
|
||||||
|
raise self._get_selection_raises
|
||||||
|
if self._get_selection_result is None:
|
||||||
|
return ("", None)
|
||||||
|
return self._get_selection_result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_app() -> App:
|
||||||
|
app = MagicMock(spec=App)
|
||||||
|
app.query = MagicMock(return_value=[])
|
||||||
|
app.notify = MagicMock()
|
||||||
|
app.copy_to_clipboard = MagicMock()
|
||||||
|
return cast(App, app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"widgets,description",
|
||||||
|
[
|
||||||
|
([], "no widgets"),
|
||||||
|
([MockWidget(text_selection=None)], "no selection"),
|
||||||
|
([MockWidget()], "widget without text_selection attr"),
|
||||||
|
(
|
||||||
|
[
|
||||||
|
MockWidget(
|
||||||
|
text_selection=SimpleNamespace(),
|
||||||
|
get_selection_raises=ValueError("Error getting selection"),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
"get_selection raises",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[MockWidget(text_selection=SimpleNamespace(), get_selection_result=None)],
|
||||||
|
"empty result",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[
|
||||||
|
MockWidget(
|
||||||
|
text_selection=SimpleNamespace(), get_selection_result=(" ", None)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
"empty text",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_copy_selection_to_clipboard_no_notification(
|
||||||
|
mock_app: MagicMock, widgets: list[MockWidget], description: str
|
||||||
|
) -> None:
|
||||||
|
if description == "widget without text_selection attr":
|
||||||
|
del widgets[0].text_selection
|
||||||
|
mock_app.query.return_value = widgets
|
||||||
|
|
||||||
|
copy_selection_to_clipboard(mock_app)
|
||||||
|
mock_app.notify.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("vibe.cli.clipboard._copy_osc52")
|
||||||
|
@patch("vibe.cli.clipboard.pyperclip.copy")
|
||||||
|
def test_copy_selection_to_clipboard_success_with_osc52(
|
||||||
|
mock_pyperclip_copy: MagicMock, mock_osc52_copy: MagicMock, mock_app: MagicMock
|
||||||
|
) -> None:
|
||||||
|
widget = MockWidget(
|
||||||
|
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
|
||||||
|
)
|
||||||
|
mock_app.query.return_value = [widget]
|
||||||
|
|
||||||
|
copy_selection_to_clipboard(mock_app)
|
||||||
|
|
||||||
|
mock_osc52_copy.assert_called_once_with("selected text")
|
||||||
|
mock_pyperclip_copy.assert_not_called()
|
||||||
|
mock_app.copy_to_clipboard.assert_not_called()
|
||||||
|
mock_app.notify.assert_called_once_with(
|
||||||
|
'"selected text" copied to clipboard', severity="information", timeout=2
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("vibe.cli.clipboard._copy_osc52")
|
||||||
|
@patch("vibe.cli.clipboard.pyperclip.copy")
|
||||||
|
def test_copy_selection_to_clipboard_osc52_fails_success_with_pyperclip(
|
||||||
|
mock_pyperclip_copy: MagicMock, mock_osc52_copy: MagicMock, mock_app: MagicMock
|
||||||
|
) -> None:
|
||||||
|
widget = MockWidget(
|
||||||
|
text_selection=SimpleNamespace(),
|
||||||
|
get_selection_result=(" selected text ", None),
|
||||||
|
)
|
||||||
|
mock_app.query.return_value = [widget]
|
||||||
|
mock_osc52_copy.side_effect = Exception("osc52 failed")
|
||||||
|
|
||||||
|
copy_selection_to_clipboard(mock_app)
|
||||||
|
|
||||||
|
mock_osc52_copy.assert_called_once_with(" selected text ")
|
||||||
|
mock_pyperclip_copy.assert_called_once_with(" selected text ")
|
||||||
|
mock_app.notify.assert_called_once_with(
|
||||||
|
'" selected text " copied to clipboard', severity="information", timeout=2
|
||||||
|
)
|
||||||
|
mock_app.copy_to_clipboard.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("vibe.cli.clipboard._copy_osc52")
|
||||||
|
@patch("vibe.cli.clipboard.pyperclip.copy")
|
||||||
|
def test_copy_selection_to_clipboard_osc52_and_pyperclip_fail_success_with_app_copy(
|
||||||
|
mock_pyperclip_copy: MagicMock, mock_osc52_copy: MagicMock, mock_app: MagicMock
|
||||||
|
) -> None:
|
||||||
|
widget = MockWidget(
|
||||||
|
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
|
||||||
|
)
|
||||||
|
mock_app.query.return_value = [widget]
|
||||||
|
mock_osc52_copy.side_effect = Exception("osc52 failed")
|
||||||
|
mock_pyperclip_copy.side_effect = Exception("pyperclip failed")
|
||||||
|
|
||||||
|
copy_selection_to_clipboard(mock_app)
|
||||||
|
|
||||||
|
mock_osc52_copy.assert_called_once_with("selected text")
|
||||||
|
mock_pyperclip_copy.assert_called_once_with("selected text")
|
||||||
|
mock_app.copy_to_clipboard.assert_called_once_with("selected text")
|
||||||
|
mock_app.notify.assert_called_once_with(
|
||||||
|
'"selected text" copied to clipboard', severity="information", timeout=2
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("vibe.cli.clipboard._copy_osc52")
|
||||||
|
@patch("vibe.cli.clipboard.pyperclip.copy")
|
||||||
|
def test_copy_selection_to_clipboard_all_methods_fail(
|
||||||
|
mock_pyperclip_copy: MagicMock, mock_osc52_copy: MagicMock, mock_app: MagicMock
|
||||||
|
) -> None:
|
||||||
|
widget = MockWidget(
|
||||||
|
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
|
||||||
|
)
|
||||||
|
mock_app.query.return_value = [widget]
|
||||||
|
mock_osc52_copy.side_effect = Exception("osc52 failed")
|
||||||
|
mock_pyperclip_copy.side_effect = Exception("pyperclip failed")
|
||||||
|
mock_app.copy_to_clipboard.side_effect = Exception("app copy failed")
|
||||||
|
|
||||||
|
copy_selection_to_clipboard(mock_app)
|
||||||
|
|
||||||
|
mock_osc52_copy.assert_called_once_with("selected text")
|
||||||
|
mock_pyperclip_copy.assert_called_once_with("selected text")
|
||||||
|
mock_app.copy_to_clipboard.assert_called_once_with("selected text")
|
||||||
|
mock_app.notify.assert_called_once_with(
|
||||||
|
"Failed to copy - no clipboard method available", severity="warning", timeout=3
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> None:
|
||||||
|
widget1 = MockWidget(
|
||||||
|
text_selection=SimpleNamespace(), get_selection_result=("first selection", None)
|
||||||
|
)
|
||||||
|
widget2 = MockWidget(
|
||||||
|
text_selection=SimpleNamespace(),
|
||||||
|
get_selection_result=("second selection", None),
|
||||||
|
)
|
||||||
|
widget3 = MockWidget(text_selection=None)
|
||||||
|
mock_app.query.return_value = [widget1, widget2, widget3]
|
||||||
|
|
||||||
|
with patch("vibe.cli.clipboard._copy_osc52") as mock_osc52_copy:
|
||||||
|
copy_selection_to_clipboard(mock_app)
|
||||||
|
|
||||||
|
mock_osc52_copy.assert_called_once_with("first selection\nsecond selection")
|
||||||
|
mock_app.notify.assert_called_once_with(
|
||||||
|
'"first selection⏎second selection" copied to clipboard',
|
||||||
|
severity="information",
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_selection_to_clipboard_preview_shortening(mock_app: MagicMock) -> None:
|
||||||
|
long_text = "a" * 100
|
||||||
|
widget = MockWidget(
|
||||||
|
text_selection=SimpleNamespace(), get_selection_result=(long_text, None)
|
||||||
|
)
|
||||||
|
mock_app.query.return_value = [widget]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("vibe.cli.clipboard._copy_osc52") as mock_osc52_copy,
|
||||||
|
patch("vibe.cli.clipboard.pyperclip.copy") as mock_pyperclip_copy,
|
||||||
|
):
|
||||||
|
mock_osc52_copy.side_effect = Exception("osc52 failed")
|
||||||
|
copy_selection_to_clipboard(mock_app)
|
||||||
|
|
||||||
|
mock_osc52_copy.assert_called_once_with(long_text)
|
||||||
|
mock_pyperclip_copy.assert_called_once_with(long_text)
|
||||||
|
notification_call = mock_app.notify.call_args
|
||||||
|
assert notification_call is not None
|
||||||
|
assert '"' in notification_call[0][0]
|
||||||
|
assert "copied to clipboard" in notification_call[0][0]
|
||||||
|
assert len(notification_call[0][0]) < len(long_text) + 30
|
||||||
|
|
||||||
|
|
||||||
|
@patch("builtins.open", new_callable=mock_open)
|
||||||
|
def test_copy_osc52_writes_correct_sequence(
|
||||||
|
mock_file: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("TMUX", raising=False)
|
||||||
|
test_text = "héllo wörld 🎉"
|
||||||
|
|
||||||
|
_copy_osc52(test_text)
|
||||||
|
|
||||||
|
encoded = base64.b64encode(test_text.encode("utf-8")).decode("ascii")
|
||||||
|
expected_seq = f"\033]52;c;{encoded}\a"
|
||||||
|
mock_file.assert_called_once_with("/dev/tty", "w")
|
||||||
|
handle = mock_file()
|
||||||
|
handle.write.assert_called_once_with(expected_seq)
|
||||||
|
handle.flush.assert_called_once()
|
||||||
|
|
@ -1,74 +1,48 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic.fields import FieldInfo
|
|
||||||
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import tomli_w
|
||||||
|
|
||||||
_in_mem_config: dict[str, Any] = {}
|
from vibe.core import config_path
|
||||||
|
|
||||||
|
|
||||||
class InMemSettingsSource(PydanticBaseSettingsSource):
|
def get_base_config() -> dict[str, Any]:
|
||||||
def __init__(self, settings_cls: type[BaseSettings]) -> None:
|
return {
|
||||||
super().__init__(settings_cls)
|
"active_model": "devstral-latest",
|
||||||
|
"providers": [
|
||||||
def get_field_value(
|
{
|
||||||
self, field: FieldInfo, field_name: str
|
"name": "mistral",
|
||||||
) -> tuple[Any, str, bool]:
|
"api_base": "https://api.mistral.ai/v1",
|
||||||
return _in_mem_config.get(field_name), field_name, False
|
"api_key_env_var": "MISTRAL_API_KEY",
|
||||||
|
"backend": "mistral",
|
||||||
def __call__(self) -> dict[str, Any]:
|
}
|
||||||
return _in_mem_config
|
],
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
@pytest.fixture(autouse=True, scope="session")
|
"name": "mistral-vibe-cli-latest",
|
||||||
def _patch_vibe_config() -> None:
|
"provider": "mistral",
|
||||||
"""Patch VibeConfig.settings_customise_sources to only use init_settings in tests.
|
"alias": "devstral-latest",
|
||||||
|
}
|
||||||
This ensures that even production code that creates VibeConfig instances
|
],
|
||||||
will only use init_settings and ignore environment variables and config files.
|
}
|
||||||
Runs once per test session before any tests execute.
|
|
||||||
"""
|
|
||||||
from vibe.core.config import VibeConfig
|
|
||||||
|
|
||||||
def patched_settings_customise_sources(
|
|
||||||
cls,
|
|
||||||
settings_cls: type[BaseSettings],
|
|
||||||
init_settings: PydanticBaseSettingsSource,
|
|
||||||
env_settings: PydanticBaseSettingsSource,
|
|
||||||
dotenv_settings: PydanticBaseSettingsSource,
|
|
||||||
file_secret_settings: PydanticBaseSettingsSource,
|
|
||||||
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
||||||
return (init_settings, InMemSettingsSource(settings_cls))
|
|
||||||
|
|
||||||
VibeConfig.settings_customise_sources = classmethod(
|
|
||||||
patched_settings_customise_sources
|
|
||||||
) # type: ignore[assignment]
|
|
||||||
|
|
||||||
def dump_config(cls, config: dict[str, Any]) -> None:
|
|
||||||
global _in_mem_config
|
|
||||||
_in_mem_config = config
|
|
||||||
|
|
||||||
VibeConfig.dump_config = classmethod(dump_config) # type: ignore[assignment]
|
|
||||||
|
|
||||||
def patched_load(cls, agent: str | None = None, **overrides: Any) -> Any:
|
|
||||||
return cls(**overrides)
|
|
||||||
|
|
||||||
VibeConfig.load = classmethod(patched_load) # type: ignore[assignment]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _reset_in_mem_config() -> None:
|
def config_dir(
|
||||||
"""Reset in-memory config before each test to prevent test isolation issues.
|
monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
|
||||||
|
) -> Path:
|
||||||
|
tmp_path = tmp_path_factory.mktemp("vibe")
|
||||||
|
config_dir = tmp_path / ".vibe"
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
config_file = config_dir / "config.toml"
|
||||||
|
config_file.write_text(tomli_w.dumps(get_base_config()), encoding="utf-8")
|
||||||
|
|
||||||
This ensures that each test starts with a clean configuration state,
|
monkeypatch.setattr(config_path, "_DEFAULT_VIBE_HOME", config_dir)
|
||||||
preventing race conditions and test interference when tests run in parallel
|
return config_dir
|
||||||
or when VibeConfig.save_updates() modifies the shared _in_mem_config dict.
|
|
||||||
"""
|
|
||||||
global _in_mem_config
|
|
||||||
_in_mem_config = {}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|
|
||||||
41
tests/core/test_config_resolution.py
Normal file
41
tests/core/test_config_resolution.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from vibe.core.config_path import CONFIG_FILE, GLOBAL_CONFIG_FILE, VIBE_HOME
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveConfigFile:
|
||||||
|
def test_resolves_local_config_when_exists(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Test that local .vibe/config.toml is found when it exists."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
local_config_dir = tmp_path / ".vibe"
|
||||||
|
local_config_dir.mkdir()
|
||||||
|
local_config = local_config_dir / "config.toml"
|
||||||
|
local_config.write_text('active_model = "test"', encoding="utf-8")
|
||||||
|
|
||||||
|
assert CONFIG_FILE.path == local_config
|
||||||
|
assert CONFIG_FILE.path.is_file()
|
||||||
|
assert CONFIG_FILE.path.read_text(encoding="utf-8") == 'active_model = "test"'
|
||||||
|
|
||||||
|
def test_falls_back_to_global_config_when_local_missing(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Test that global config is returned when local config doesn't exist."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
# Ensure no local config exists
|
||||||
|
assert not (tmp_path / ".vibe" / "config.toml").exists()
|
||||||
|
|
||||||
|
assert CONFIG_FILE.path == GLOBAL_CONFIG_FILE.path
|
||||||
|
|
||||||
|
def test_respects_vibe_home_env_var(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Test that VIBE_HOME environment variable affects VIBE_HOME.path."""
|
||||||
|
assert VIBE_HOME.path != tmp_path
|
||||||
|
monkeypatch.setenv("VIBE_HOME", str(tmp_path))
|
||||||
|
assert VIBE_HOME.path == tmp_path
|
||||||
|
|
@ -20,12 +20,6 @@ class StubApp(App[str | None]):
|
||||||
return self._return_value
|
return self._return_value
|
||||||
|
|
||||||
|
|
||||||
def _patch_env_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
|
||||||
env_file = tmp_path / ".env"
|
|
||||||
monkeypatch.setattr(onboarding, "GLOBAL_ENV_FILE", env_file, raising=False)
|
|
||||||
return env_file
|
|
||||||
|
|
||||||
|
|
||||||
def _exit_raiser(code: int = 0) -> None:
|
def _exit_raiser(code: int = 0) -> None:
|
||||||
raise SystemExit(code)
|
raise SystemExit(code)
|
||||||
|
|
||||||
|
|
@ -33,7 +27,6 @@ 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], tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
_patch_env_file(monkeypatch, tmp_path)
|
|
||||||
monkeypatch.setattr(sys, "exit", _exit_raiser)
|
monkeypatch.setattr(sys, "exit", _exit_raiser)
|
||||||
|
|
||||||
with pytest.raises(SystemExit) as excinfo:
|
with pytest.raises(SystemExit) as excinfo:
|
||||||
|
|
@ -47,7 +40,6 @@ 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], tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
_patch_env_file(monkeypatch, tmp_path)
|
|
||||||
monkeypatch.setattr(sys, "exit", _exit_raiser)
|
monkeypatch.setattr(sys, "exit", _exit_raiser)
|
||||||
|
|
||||||
onboarding.run_onboarding(StubApp("save_error:disk full"))
|
onboarding.run_onboarding(StubApp("save_error:disk full"))
|
||||||
|
|
@ -60,7 +52,6 @@ def test_warns_on_save_error(
|
||||||
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], tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
_patch_env_file(monkeypatch, tmp_path)
|
|
||||||
monkeypatch.setattr(sys, "exit", _exit_raiser)
|
monkeypatch.setattr(sys, "exit", _exit_raiser)
|
||||||
|
|
||||||
onboarding.run_onboarding(StubApp("completed"))
|
onboarding.run_onboarding(StubApp("completed"))
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
import tomllib
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from textual.events import Resize
|
from textual.events import Resize
|
||||||
|
|
@ -10,9 +10,8 @@ from textual.geometry import Size
|
||||||
from textual.pilot import Pilot
|
from textual.pilot import Pilot
|
||||||
from textual.widgets import Input
|
from textual.widgets import Input
|
||||||
|
|
||||||
from vibe.core import config as core_config
|
from vibe.core.config_path import GLOBAL_CONFIG_FILE, GLOBAL_ENV_FILE
|
||||||
from vibe.setup.onboarding import OnboardingApp
|
from vibe.setup.onboarding import OnboardingApp
|
||||||
import vibe.setup.onboarding.screens.api_key as api_key_module
|
|
||||||
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
|
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
|
||||||
from vibe.setup.onboarding.screens.theme_selection import THEMES, ThemeSelectionScreen
|
from vibe.setup.onboarding.screens.theme_selection import THEMES, ThemeSelectionScreen
|
||||||
|
|
||||||
|
|
@ -31,32 +30,6 @@ async def _wait_for(
|
||||||
raise AssertionError(msg)
|
raise AssertionError(msg)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def onboarding_app(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> tuple[OnboardingApp, Path, dict[str, Any]]:
|
|
||||||
vibe_home = tmp_path / ".vibe"
|
|
||||||
env_file = vibe_home / ".env"
|
|
||||||
saved_updates: dict[str, Any] = {}
|
|
||||||
|
|
||||||
def record_updates(updates: dict[str, Any]) -> None:
|
|
||||||
saved_updates.update(updates)
|
|
||||||
|
|
||||||
monkeypatch.setenv("VIBE_HOME", str(vibe_home))
|
|
||||||
|
|
||||||
for module in (core_config, api_key_module):
|
|
||||||
monkeypatch.setattr(module, "GLOBAL_CONFIG_DIR", vibe_home, raising=False)
|
|
||||||
monkeypatch.setattr(module, "GLOBAL_ENV_FILE", env_file, raising=False)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
core_config.VibeConfig,
|
|
||||||
"save_updates",
|
|
||||||
classmethod(lambda cls, updates: record_updates(updates)),
|
|
||||||
)
|
|
||||||
|
|
||||||
return OnboardingApp(), env_file, saved_updates
|
|
||||||
|
|
||||||
|
|
||||||
async def pass_welcome_screen(pilot: Pilot) -> None:
|
async def pass_welcome_screen(pilot: Pilot) -> None:
|
||||||
welcome_screen = pilot.app.get_screen("welcome")
|
welcome_screen = pilot.app.get_screen("welcome")
|
||||||
await _wait_for(
|
await _wait_for(
|
||||||
|
|
@ -67,10 +40,8 @@ async def pass_welcome_screen(pilot: Pilot) -> None:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ui_gets_through_the_onboarding_successfully(
|
async def test_ui_gets_through_the_onboarding_successfully() -> None:
|
||||||
onboarding_app: tuple[OnboardingApp, Path, dict[str, Any]],
|
app = OnboardingApp()
|
||||||
) -> None:
|
|
||||||
app, env_file, config_updates = onboarding_app
|
|
||||||
api_key_value = "sk-onboarding-test-key"
|
api_key_value = "sk-onboarding-test-key"
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
|
|
@ -88,19 +59,20 @@ async def test_ui_gets_through_the_onboarding_successfully(
|
||||||
|
|
||||||
assert app.return_value == "completed"
|
assert app.return_value == "completed"
|
||||||
|
|
||||||
assert env_file.is_file()
|
assert GLOBAL_ENV_FILE.path.is_file()
|
||||||
env_contents = env_file.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
|
||||||
|
|
||||||
assert config_updates.get("textual_theme") == app.theme
|
assert GLOBAL_CONFIG_FILE.path.is_file()
|
||||||
|
config_contents = GLOBAL_CONFIG_FILE.path.read_text(encoding="utf-8")
|
||||||
|
config_dict = tomllib.loads(config_contents)
|
||||||
|
assert config_dict.get("textual_theme") == app.theme
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ui_can_pick_a_theme_and_saves_selection(
|
async def test_ui_can_pick_a_theme_and_saves_selection(config_dir: Path) -> None:
|
||||||
onboarding_app: tuple[OnboardingApp, Path, dict[str, Any]],
|
app = OnboardingApp()
|
||||||
) -> None:
|
|
||||||
app, _, config_updates = onboarding_app
|
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await pass_welcome_screen(pilot)
|
await pass_welcome_screen(pilot)
|
||||||
|
|
@ -121,4 +93,7 @@ async def test_ui_can_pick_a_theme_and_saves_selection(
|
||||||
await pilot.press("enter")
|
await pilot.press("enter")
|
||||||
await _wait_for(lambda: isinstance(app.screen, ApiKeyScreen), pilot)
|
await _wait_for(lambda: isinstance(app.screen, ApiKeyScreen), pilot)
|
||||||
|
|
||||||
assert config_updates.get("textual_theme") == target_theme
|
assert GLOBAL_CONFIG_FILE.path.is_file()
|
||||||
|
config_contents = GLOBAL_CONFIG_FILE.path.read_text(encoding="utf-8")
|
||||||
|
config_dict = tomllib.loads(config_contents)
|
||||||
|
assert config_dict.get("textual_theme") == target_theme
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,13 @@ from textual.pilot import Pilot
|
||||||
|
|
||||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
|
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
|
||||||
from tests.snapshots.snap_compare import SnapCompare
|
from tests.snapshots.snap_compare import SnapCompare
|
||||||
from vibe.cli.update_notifier import FakeVersionUpdateGateway, VersionUpdate
|
from tests.update_notifier.adapters.fake_update_cache_repository import (
|
||||||
|
FakeUpdateCacheRepository,
|
||||||
|
)
|
||||||
|
from tests.update_notifier.adapters.fake_version_update_gateway import (
|
||||||
|
FakeVersionUpdateGateway,
|
||||||
|
)
|
||||||
|
from vibe.cli.update_notifier import VersionUpdate
|
||||||
|
|
||||||
|
|
||||||
class SnapshotTestAppWithUpdate(BaseSnapshotTestApp):
|
class SnapshotTestAppWithUpdate(BaseSnapshotTestApp):
|
||||||
|
|
@ -14,9 +20,11 @@ class SnapshotTestAppWithUpdate(BaseSnapshotTestApp):
|
||||||
version_update_notifier = FakeVersionUpdateGateway(
|
version_update_notifier = FakeVersionUpdateGateway(
|
||||||
update=VersionUpdate(latest_version="1000.2.0")
|
update=VersionUpdate(latest_version="1000.2.0")
|
||||||
)
|
)
|
||||||
|
update_cache_repository = FakeUpdateCacheRepository()
|
||||||
super().__init__(
|
super().__init__(
|
||||||
config=config,
|
config=config,
|
||||||
version_update_notifier=version_update_notifier,
|
version_update_notifier=version_update_notifier,
|
||||||
|
update_cache_repository=update_cache_repository,
|
||||||
current_version="1.0.4",
|
current_version="1.0.4",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from vibe.core.middleware import (
|
||||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||||
from vibe.core.tools.builtins.todo import TodoArgs
|
from vibe.core.tools.builtins.todo import TodoArgs
|
||||||
from vibe.core.types import (
|
from vibe.core.types import (
|
||||||
|
ApprovalResponse,
|
||||||
AssistantEvent,
|
AssistantEvent,
|
||||||
FunctionCall,
|
FunctionCall,
|
||||||
LLMChunk,
|
LLMChunk,
|
||||||
|
|
@ -30,11 +31,7 @@ from vibe.core.types import (
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
ToolResultEvent,
|
ToolResultEvent,
|
||||||
)
|
)
|
||||||
from vibe.core.utils import (
|
from vibe.core.utils import CancellationReason, get_user_cancellation_message
|
||||||
ApprovalResponse,
|
|
||||||
CancellationReason,
|
|
||||||
get_user_cancellation_message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class InjectBeforeMiddleware:
|
class InjectBeforeMiddleware:
|
||||||
|
|
@ -147,23 +144,6 @@ async def test_act_emits_user_and_assistant_msgs(observer_capture) -> None:
|
||||||
assert observed[2][1] == "Pong!"
|
assert observed[2][1] == "Pong!"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_act_yields_assistant_event_with_usage_stats() -> None:
|
|
||||||
backend = FakeBackend([mock_llm_chunk(content="Pong!")])
|
|
||||||
agent = Agent(make_config(), backend=backend)
|
|
||||||
|
|
||||||
events = [ev async for ev in agent.act("Ping?")]
|
|
||||||
|
|
||||||
assert len(events) == 1
|
|
||||||
ev = events[-1]
|
|
||||||
assert isinstance(ev, AssistantEvent)
|
|
||||||
assert ev.content == "Pong!"
|
|
||||||
# stats come from tests.mock.utils.mock_llm_result (prompt=10, completion=5)
|
|
||||||
assert ev.prompt_tokens == 10
|
|
||||||
assert ev.completion_tokens == 5
|
|
||||||
assert ev.session_total_tokens == 15
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_act_streams_batched_chunks_in_order() -> None:
|
async def test_act_streams_batched_chunks_in_order() -> None:
|
||||||
backend = FakeBackend([
|
backend = FakeBackend([
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from vibe.core.config import SessionLoggingConfig, VibeConfig
|
||||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||||
from vibe.core.tools.builtins.todo import TodoItem
|
from vibe.core.tools.builtins.todo import TodoItem
|
||||||
from vibe.core.types import (
|
from vibe.core.types import (
|
||||||
|
ApprovalResponse,
|
||||||
AssistantEvent,
|
AssistantEvent,
|
||||||
BaseEvent,
|
BaseEvent,
|
||||||
FunctionCall,
|
FunctionCall,
|
||||||
|
|
@ -24,7 +25,6 @@ from vibe.core.types import (
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
ToolResultEvent,
|
ToolResultEvent,
|
||||||
)
|
)
|
||||||
from vibe.core.utils import ApprovalResponse
|
|
||||||
|
|
||||||
|
|
||||||
async def act_and_collect_events(agent: Agent, prompt: str) -> list[BaseEvent]:
|
async def act_and_collect_events(agent: Agent, prompt: str) -> list[BaseEvent]:
|
||||||
|
|
@ -140,7 +140,7 @@ async def test_tool_call_requires_approval_if_not_auto_approved() -> None:
|
||||||
async def test_tool_call_approved_by_callback() -> None:
|
async def test_tool_call_approved_by_callback() -> None:
|
||||||
def approval_callback(
|
def approval_callback(
|
||||||
_tool_name: str, _args: dict[str, Any], _tool_call_id: str
|
_tool_name: str, _args: dict[str, Any], _tool_call_id: str
|
||||||
) -> tuple[str, str | None]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
return (ApprovalResponse.YES, None)
|
return (ApprovalResponse.YES, None)
|
||||||
|
|
||||||
agent = make_agent(
|
agent = make_agent(
|
||||||
|
|
@ -175,7 +175,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
|
||||||
|
|
||||||
def approval_callback(
|
def approval_callback(
|
||||||
_tool_name: str, _args: dict[str, Any], _tool_call_id: str
|
_tool_name: str, _args: dict[str, Any], _tool_call_id: str
|
||||||
) -> tuple[str, str | None]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
return (ApprovalResponse.NO, custom_feedback)
|
return (ApprovalResponse.NO, custom_feedback)
|
||||||
|
|
||||||
agent = make_agent(
|
agent = make_agent(
|
||||||
|
|
@ -237,14 +237,20 @@ async def test_tool_call_skipped_when_permission_is_never() -> None:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_approval_always_flips_auto_approve_for_subsequent_calls() -> None:
|
async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> None:
|
||||||
callback_invocations = []
|
callback_invocations = []
|
||||||
|
agent_ref: Agent | None = None
|
||||||
|
|
||||||
def approval_callback(
|
def approval_callback(
|
||||||
tool_name: str, _args: dict[str, Any], _tool_call_id: str
|
tool_name: str, _args: dict[str, Any], _tool_call_id: str
|
||||||
) -> tuple[str, str | None]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
callback_invocations.append(tool_name)
|
callback_invocations.append(tool_name)
|
||||||
return (ApprovalResponse.ALWAYS, None)
|
# Set permission to ALWAYS for this tool (simulating the new behavior)
|
||||||
|
assert agent_ref is not None
|
||||||
|
if tool_name not in agent_ref.config.tools:
|
||||||
|
agent_ref.config.tools[tool_name] = BaseToolConfig()
|
||||||
|
agent_ref.config.tools[tool_name].permission = ToolPermission.ALWAYS
|
||||||
|
return (ApprovalResponse.YES, None)
|
||||||
|
|
||||||
agent = make_agent(
|
agent = make_agent(
|
||||||
auto_approve=False,
|
auto_approve=False,
|
||||||
|
|
@ -261,11 +267,16 @@ async def test_approval_always_flips_auto_approve_for_subsequent_calls() -> None
|
||||||
mock_llm_chunk(content="Second done.", finish_reason="stop"),
|
mock_llm_chunk(content="Second done.", finish_reason="stop"),
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
|
agent_ref = agent
|
||||||
|
|
||||||
events1 = await act_and_collect_events(agent, "First request")
|
events1 = await act_and_collect_events(agent, "First request")
|
||||||
events2 = await act_and_collect_events(agent, "Second request")
|
events2 = await act_and_collect_events(agent, "Second request")
|
||||||
|
|
||||||
assert agent.auto_approve is True
|
tool_config_todo = agent.tool_manager.get_tool_config("todo")
|
||||||
|
assert tool_config_todo.permission is ToolPermission.ALWAYS
|
||||||
|
tool_config_help = agent.tool_manager.get_tool_config("bash")
|
||||||
|
assert tool_config_help.permission is not ToolPermission.ALWAYS
|
||||||
|
assert agent.auto_approve is False
|
||||||
assert len(callback_invocations) == 1
|
assert len(callback_invocations) == 1
|
||||||
assert callback_invocations[0] == "todo"
|
assert callback_invocations[0] == "todo"
|
||||||
assert isinstance(events1[2], ToolResultEvent)
|
assert isinstance(events1[2], ToolResultEvent)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from vibe.cli.update_notifier.ports.update_cache_repository import (
|
||||||
|
UpdateCache,
|
||||||
|
UpdateCacheRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUpdateCacheRepository(UpdateCacheRepository):
|
||||||
|
def __init__(self, update_cache: UpdateCache | None = None) -> None:
|
||||||
|
self.update_cache: UpdateCache | None = update_cache
|
||||||
|
|
||||||
|
async def get(self) -> UpdateCache | None:
|
||||||
|
return self.update_cache
|
||||||
|
|
||||||
|
async def set(self, update_cache: UpdateCache) -> None:
|
||||||
|
self.update_cache = update_cache
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from vibe.cli.update_notifier.version_update_gateway import (
|
from vibe.cli.update_notifier.ports.version_update_gateway import (
|
||||||
VersionUpdate,
|
VersionUpdate,
|
||||||
VersionUpdateGateway,
|
VersionUpdateGateway,
|
||||||
VersionUpdateGatewayError,
|
VersionUpdateGatewayError,
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from vibe.cli.update_notifier.adapters.filesystem_update_cache_repository import (
|
||||||
|
FileSystemUpdateCacheRepository,
|
||||||
|
)
|
||||||
|
from vibe.cli.update_notifier.ports.update_cache_repository import UpdateCache
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reads_cache_from_file_when_present(tmp_path: Path) -> None:
|
||||||
|
cache_file = tmp_path / "update_cache.json"
|
||||||
|
cache_file.write_text(
|
||||||
|
json.dumps({"latest_version": "1.2.3", "stored_at_timestamp": 1_700_000_000})
|
||||||
|
)
|
||||||
|
repository = FileSystemUpdateCacheRepository(base_path=tmp_path)
|
||||||
|
|
||||||
|
cache = await repository.get()
|
||||||
|
|
||||||
|
assert cache is not None
|
||||||
|
assert cache.latest_version == "1.2.3"
|
||||||
|
assert cache.stored_at_timestamp == 1_700_000_000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_none_when_cache_file_is_missing(tmp_path: Path) -> None:
|
||||||
|
repository = FileSystemUpdateCacheRepository(base_path=tmp_path)
|
||||||
|
|
||||||
|
cache = await repository.get()
|
||||||
|
|
||||||
|
assert cache is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_none_when_cache_file_is_corrupted(tmp_path: Path) -> None:
|
||||||
|
cache_dir = tmp_path / ".vibe"
|
||||||
|
cache_dir.mkdir()
|
||||||
|
(cache_dir / "update_cache.json").write_text("{not-json")
|
||||||
|
repository = FileSystemUpdateCacheRepository(base_path=tmp_path)
|
||||||
|
|
||||||
|
cache = await repository.get()
|
||||||
|
|
||||||
|
assert cache is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_overwrites_existing_cache(tmp_path: Path) -> None:
|
||||||
|
cache_file = tmp_path / "update_cache.json"
|
||||||
|
cache_file.write_text(
|
||||||
|
json.dumps({"latest_version": "1.0.0", "stored_at_timestamp": 1_600_000_000})
|
||||||
|
)
|
||||||
|
repository = FileSystemUpdateCacheRepository(base_path=tmp_path)
|
||||||
|
|
||||||
|
await repository.set(
|
||||||
|
UpdateCache(latest_version="1.1.0", stored_at_timestamp=1_700_200_000)
|
||||||
|
)
|
||||||
|
|
||||||
|
content = json.loads(cache_file.read_text())
|
||||||
|
assert content["latest_version"] == "1.1.0"
|
||||||
|
assert content["stored_at_timestamp"] == 1_700_200_000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_silently_ignores_errors_when_writing_cache_fails(tmp_path: Path) -> None:
|
||||||
|
cache_dir = tmp_path / ".vibe"
|
||||||
|
cache_dir.mkdir()
|
||||||
|
(cache_dir / "update_cache.json").mkdir()
|
||||||
|
repository = FileSystemUpdateCacheRepository(base_path=tmp_path)
|
||||||
|
|
||||||
|
await repository.set(
|
||||||
|
UpdateCache(latest_version="1.2.0", stored_at_timestamp=1_700_300_000)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (cache_dir / "update_cache.json").is_dir()
|
||||||
|
|
@ -5,10 +5,8 @@ from collections.abc import Callable
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from vibe.cli.update_notifier.github_version_update_gateway import (
|
from vibe.cli.update_notifier import (
|
||||||
GitHubVersionUpdateGateway,
|
GitHubVersionUpdateGateway,
|
||||||
)
|
|
||||||
from vibe.cli.update_notifier.version_update_gateway import (
|
|
||||||
VersionUpdateGatewayCause,
|
VersionUpdateGatewayCause,
|
||||||
VersionUpdateGatewayError,
|
VersionUpdateGatewayError,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
157
tests/update_notifier/test_pypi_version_update_gateway.py
Normal file
157
tests/update_notifier/test_pypi_version_update_gateway.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from vibe.cli.update_notifier.adapters.pypi_version_update_gateway import (
|
||||||
|
PyPIVersionUpdateGateway,
|
||||||
|
)
|
||||||
|
from vibe.cli.update_notifier.ports.version_update_gateway import (
|
||||||
|
VersionUpdate,
|
||||||
|
VersionUpdateGatewayCause,
|
||||||
|
VersionUpdateGatewayError,
|
||||||
|
)
|
||||||
|
|
||||||
|
Handler = Callable[[httpx.Request], httpx.Response]
|
||||||
|
|
||||||
|
PYPI_API_URL = "https://pypi.org"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retrieves_nothing_when_no_versions_are_available() -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
status_code=httpx.codes.OK, json={"versions": [], "files": []}
|
||||||
|
)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url=PYPI_API_URL) as client:
|
||||||
|
gateway = PyPIVersionUpdateGateway(project_name="mistral-vibe", client=client)
|
||||||
|
update = await gateway.fetch_update()
|
||||||
|
|
||||||
|
assert update is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retrieves_the_latest_non_yanked_version() -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert request.headers["Accept"] == "application/vnd.pypi.simple.v1+json"
|
||||||
|
assert request.url.path == "/simple/mistral-vibe/"
|
||||||
|
return httpx.Response(
|
||||||
|
status_code=httpx.codes.OK,
|
||||||
|
json={
|
||||||
|
"versions": ["1.0.0", "1.0.1", "1.0.2"],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"filename": "mistral_vibe-1.0.0-py3-none-any.whl",
|
||||||
|
"yanked": False,
|
||||||
|
},
|
||||||
|
{"filename": "mistral_vibe-1.0.1-py3-none-any.whl", "yanked": True},
|
||||||
|
{
|
||||||
|
"filename": "mistral_vibe-1.0.2-py3-none-any.whl",
|
||||||
|
"yanked": False,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url=PYPI_API_URL) as client:
|
||||||
|
gateway = PyPIVersionUpdateGateway(project_name="mistral-vibe", client=client)
|
||||||
|
update = await gateway.fetch_update()
|
||||||
|
|
||||||
|
assert update == VersionUpdate(latest_version="1.0.2")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retrieves_nothing_when_only_yanked_versions_are_available() -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
status_code=httpx.codes.OK,
|
||||||
|
json={
|
||||||
|
"versions": ["1.0.0"],
|
||||||
|
"files": [
|
||||||
|
{"filename": "mistral_vibe-1.0.0-py3-none-any.whl", "yanked": True}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url=PYPI_API_URL) as client:
|
||||||
|
gateway = PyPIVersionUpdateGateway(project_name="mistral-vibe", client=client)
|
||||||
|
update = await gateway.fetch_update()
|
||||||
|
|
||||||
|
assert update is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_does_not_match_versions_by_substring() -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
status_code=httpx.codes.OK,
|
||||||
|
json={
|
||||||
|
"versions": ["1.0.1"],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"filename": "mistral_vibe-1.0.10-py3-none-any.whl",
|
||||||
|
"yanked": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url=PYPI_API_URL) as client:
|
||||||
|
gateway = PyPIVersionUpdateGateway(project_name="mistral-vibe", client=client)
|
||||||
|
update = await gateway.fetch_update()
|
||||||
|
|
||||||
|
assert update is None
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_connect_timeout(request: httpx.Request) -> httpx.Response:
|
||||||
|
raise httpx.ConnectTimeout("boom", request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("handler", "expected_cause", "expected_message"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
lambda _: httpx.Response(status_code=httpx.codes.NOT_FOUND),
|
||||||
|
VersionUpdateGatewayCause.NOT_FOUND,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
lambda _: httpx.Response(status_code=httpx.codes.FORBIDDEN),
|
||||||
|
VersionUpdateGatewayCause.FORBIDDEN,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
lambda _: httpx.Response(status_code=httpx.codes.INTERNAL_SERVER_ERROR),
|
||||||
|
VersionUpdateGatewayCause.ERROR_RESPONSE,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
lambda _: httpx.Response(status_code=httpx.codes.OK, content=b"{not-json"),
|
||||||
|
VersionUpdateGatewayCause.INVALID_RESPONSE,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(_raise_connect_timeout, VersionUpdateGatewayCause.REQUEST_FAILED, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retrieves_nothing_when_fetching_update_fails(
|
||||||
|
handler: Callable[[httpx.Request], httpx.Response],
|
||||||
|
expected_cause: VersionUpdateGatewayCause,
|
||||||
|
expected_message: str | None,
|
||||||
|
) -> None:
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url=PYPI_API_URL) as client:
|
||||||
|
gateway = PyPIVersionUpdateGateway(project_name="mistral-vibe", client=client)
|
||||||
|
with pytest.raises(VersionUpdateGatewayError) as excinfo:
|
||||||
|
await gateway.fetch_update()
|
||||||
|
|
||||||
|
assert excinfo.value.cause == expected_cause
|
||||||
|
if expected_message is not None:
|
||||||
|
assert str(excinfo.value) == expected_message
|
||||||
|
|
@ -1,16 +1,21 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from textual.app import Notification
|
from textual.app import Notification
|
||||||
|
|
||||||
from vibe.cli.textual_ui.app import VibeApp
|
from tests.update_notifier.adapters.fake_update_cache_repository import (
|
||||||
from vibe.cli.update_notifier.fake_version_update_gateway import (
|
FakeUpdateCacheRepository,
|
||||||
|
)
|
||||||
|
from tests.update_notifier.adapters.fake_version_update_gateway import (
|
||||||
FakeVersionUpdateGateway,
|
FakeVersionUpdateGateway,
|
||||||
)
|
)
|
||||||
from vibe.cli.update_notifier.version_update_gateway import (
|
from vibe.cli.textual_ui.app import VibeApp
|
||||||
|
from vibe.cli.update_notifier import (
|
||||||
|
UpdateCache,
|
||||||
VersionUpdate,
|
VersionUpdate,
|
||||||
VersionUpdateGatewayCause,
|
VersionUpdateGatewayCause,
|
||||||
VersionUpdateGatewayError,
|
VersionUpdateGatewayError,
|
||||||
|
|
@ -59,6 +64,7 @@ class VibeAppFactory(Protocol):
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
notifier: FakeVersionUpdateGateway,
|
notifier: FakeVersionUpdateGateway,
|
||||||
|
update_cache_repository: FakeUpdateCacheRepository | None = None,
|
||||||
config: VibeConfig | None = None,
|
config: VibeConfig | None = None,
|
||||||
auto_approve: bool = False,
|
auto_approve: bool = False,
|
||||||
current_version: str = "0.1.0",
|
current_version: str = "0.1.0",
|
||||||
|
|
@ -67,9 +73,13 @@ class VibeAppFactory(Protocol):
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def make_vibe_app(vibe_config_with_update_checks_enabled: VibeConfig) -> VibeAppFactory:
|
def make_vibe_app(vibe_config_with_update_checks_enabled: VibeConfig) -> VibeAppFactory:
|
||||||
|
update_cache_repository = FakeUpdateCacheRepository()
|
||||||
|
|
||||||
def _make_app(
|
def _make_app(
|
||||||
*,
|
*,
|
||||||
notifier: FakeVersionUpdateGateway,
|
notifier: FakeVersionUpdateGateway,
|
||||||
|
update_cache_repository: FakeUpdateCacheRepository
|
||||||
|
| None = update_cache_repository,
|
||||||
config: VibeConfig | None = None,
|
config: VibeConfig | None = None,
|
||||||
auto_approve: bool = False,
|
auto_approve: bool = False,
|
||||||
current_version: str = "0.1.0",
|
current_version: str = "0.1.0",
|
||||||
|
|
@ -78,6 +88,7 @@ def make_vibe_app(vibe_config_with_update_checks_enabled: VibeConfig) -> VibeApp
|
||||||
config=config or vibe_config_with_update_checks_enabled,
|
config=config or vibe_config_with_update_checks_enabled,
|
||||||
auto_approve=auto_approve,
|
auto_approve=auto_approve,
|
||||||
version_update_notifier=notifier,
|
version_update_notifier=notifier,
|
||||||
|
update_cache_repository=update_cache_repository,
|
||||||
current_version=current_version,
|
current_version=current_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -159,3 +170,52 @@ async def test_ui_does_not_invoke_gateway_nor_show_update_notification_when_upda
|
||||||
await _assert_no_notifications(app, pilot, timeout=0.3)
|
await _assert_no_notifications(app, pilot, timeout=0.3)
|
||||||
|
|
||||||
assert notifier.fetch_update_calls == 0
|
assert notifier.fetch_update_calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ui_does_not_show_toast_when_update_is_known_in_recent_cache_already(
|
||||||
|
make_vibe_app: VibeAppFactory,
|
||||||
|
) -> None:
|
||||||
|
timestamp_two_hours_ago = int(time.time()) - 2 * 60 * 60
|
||||||
|
notifier = FakeVersionUpdateGateway(update=VersionUpdate(latest_version="0.2.0"))
|
||||||
|
update_cache = UpdateCache(
|
||||||
|
latest_version="0.2.0", stored_at_timestamp=timestamp_two_hours_ago
|
||||||
|
)
|
||||||
|
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
|
||||||
|
app = make_vibe_app(
|
||||||
|
notifier=notifier, update_cache_repository=update_cache_repository
|
||||||
|
)
|
||||||
|
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await _assert_no_notifications(app, pilot, timeout=0.3)
|
||||||
|
|
||||||
|
assert notifier.fetch_update_calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ui_does_show_toast_when_cache_entry_is_too_old(
|
||||||
|
make_vibe_app: VibeAppFactory,
|
||||||
|
) -> None:
|
||||||
|
timestamp_two_days_ago = int(time.time()) - 2 * 24 * 60 * 60
|
||||||
|
notifier = FakeVersionUpdateGateway(update=VersionUpdate(latest_version="0.2.0"))
|
||||||
|
update_cache = UpdateCache(
|
||||||
|
latest_version="0.2.0", stored_at_timestamp=timestamp_two_days_ago
|
||||||
|
)
|
||||||
|
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
|
||||||
|
app = make_vibe_app(
|
||||||
|
notifier=notifier, update_cache_repository=update_cache_repository
|
||||||
|
)
|
||||||
|
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause(0.3)
|
||||||
|
notifications = list(app._notifications)
|
||||||
|
|
||||||
|
assert notifications
|
||||||
|
notification = notifications[-1]
|
||||||
|
assert notification.severity == "information"
|
||||||
|
assert notification.title == "Update available"
|
||||||
|
assert (
|
||||||
|
notification.message
|
||||||
|
== '0.1.0 => 0.2.0\nRun "uv tool upgrade mistral-vibe" to update'
|
||||||
|
)
|
||||||
|
assert notifier.fetch_update_calls == 1
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,27 @@ from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from vibe.cli.update_notifier.fake_version_update_gateway import (
|
from tests.update_notifier.adapters.fake_update_cache_repository import (
|
||||||
|
FakeUpdateCacheRepository,
|
||||||
|
)
|
||||||
|
from tests.update_notifier.adapters.fake_version_update_gateway import (
|
||||||
FakeVersionUpdateGateway,
|
FakeVersionUpdateGateway,
|
||||||
)
|
)
|
||||||
from vibe.cli.update_notifier.version_update import (
|
from vibe.cli.update_notifier import (
|
||||||
VersionUpdateError,
|
UpdateCache,
|
||||||
is_version_update_available,
|
|
||||||
)
|
|
||||||
from vibe.cli.update_notifier.version_update_gateway import (
|
|
||||||
VersionUpdate,
|
VersionUpdate,
|
||||||
VersionUpdateGatewayCause,
|
VersionUpdateGatewayCause,
|
||||||
VersionUpdateGatewayError,
|
VersionUpdateGatewayError,
|
||||||
)
|
)
|
||||||
|
from vibe.cli.update_notifier.version_update import (
|
||||||
|
VersionUpdateError,
|
||||||
|
get_update_if_available,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def current_timestamp() -> int:
|
||||||
|
return 1765278683
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -23,8 +32,10 @@ async def test_retrieves_the_latest_version_update_when_available() -> None:
|
||||||
update=VersionUpdate(latest_version=latest_update)
|
update=VersionUpdate(latest_version=latest_update)
|
||||||
)
|
)
|
||||||
|
|
||||||
update = await is_version_update_available(
|
update = await get_update_if_available(
|
||||||
version_update_notifier, current_version="1.0.0"
|
version_update_notifier,
|
||||||
|
current_version="1.0.0",
|
||||||
|
update_cache_repository=FakeUpdateCacheRepository(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert update is not None
|
assert update is not None
|
||||||
|
|
@ -39,8 +50,10 @@ async def test_retrieves_nothing_when_the_current_version_is_the_latest() -> Non
|
||||||
update=VersionUpdate(latest_version=latest_version)
|
update=VersionUpdate(latest_version=latest_version)
|
||||||
)
|
)
|
||||||
|
|
||||||
update = await is_version_update_available(
|
update = await get_update_if_available(
|
||||||
version_update_notifier, current_version=current_version
|
version_update_notifier,
|
||||||
|
current_version=current_version,
|
||||||
|
update_cache_repository=FakeUpdateCacheRepository(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert update is None
|
assert update is None
|
||||||
|
|
@ -56,8 +69,10 @@ async def test_retrieves_nothing_when_the_current_version_is_greater_than_the_la
|
||||||
update=VersionUpdate(latest_version=latest_version)
|
update=VersionUpdate(latest_version=latest_version)
|
||||||
)
|
)
|
||||||
|
|
||||||
update = await is_version_update_available(
|
update = await get_update_if_available(
|
||||||
version_update_notifier, current_version=current_version
|
version_update_notifier,
|
||||||
|
current_version=current_version,
|
||||||
|
update_cache_repository=FakeUpdateCacheRepository(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert update is None
|
assert update is None
|
||||||
|
|
@ -67,8 +82,10 @@ async def test_retrieves_nothing_when_the_current_version_is_greater_than_the_la
|
||||||
async def test_retrieves_nothing_when_no_version_is_available() -> None:
|
async def test_retrieves_nothing_when_no_version_is_available() -> None:
|
||||||
version_update_notifier = FakeVersionUpdateGateway(update=None)
|
version_update_notifier = FakeVersionUpdateGateway(update=None)
|
||||||
|
|
||||||
update = await is_version_update_available(
|
update = await get_update_if_available(
|
||||||
version_update_notifier, current_version="1.0.0"
|
version_update_notifier,
|
||||||
|
current_version="1.0.0",
|
||||||
|
update_cache_repository=FakeUpdateCacheRepository(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert update is None
|
assert update is None
|
||||||
|
|
@ -80,8 +97,10 @@ async def test_retrieves_nothing_when_latest_version_is_invalid() -> None:
|
||||||
update=VersionUpdate(latest_version="invalid-version")
|
update=VersionUpdate(latest_version="invalid-version")
|
||||||
)
|
)
|
||||||
|
|
||||||
update = await is_version_update_available(
|
update = await get_update_if_available(
|
||||||
version_update_notifier, current_version="1.0.0"
|
version_update_notifier,
|
||||||
|
current_version="1.0.0",
|
||||||
|
update_cache_repository=FakeUpdateCacheRepository(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert update is None
|
assert update is None
|
||||||
|
|
@ -96,8 +115,10 @@ async def test_replaces_hyphens_with_plus_signs_in_latest_version_to_conform_wit
|
||||||
update=VersionUpdate(latest_version="1.6.1-jetbrains")
|
update=VersionUpdate(latest_version="1.6.1-jetbrains")
|
||||||
)
|
)
|
||||||
|
|
||||||
update = await is_version_update_available(
|
update = await get_update_if_available(
|
||||||
version_update_notifier, current_version="1.0.0"
|
version_update_notifier,
|
||||||
|
current_version="1.0.0",
|
||||||
|
update_cache_repository=FakeUpdateCacheRepository(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert update is not None
|
assert update is not None
|
||||||
|
|
@ -110,8 +131,10 @@ async def test_retrieves_nothing_when_current_version_is_invalid() -> None:
|
||||||
update=VersionUpdate(latest_version="1.0.1")
|
update=VersionUpdate(latest_version="1.0.1")
|
||||||
)
|
)
|
||||||
|
|
||||||
update = await is_version_update_available(
|
update = await get_update_if_available(
|
||||||
version_update_notifier, current_version="invalid-version"
|
version_update_notifier,
|
||||||
|
current_version="invalid-version",
|
||||||
|
update_cache_repository=FakeUpdateCacheRepository(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert update is None
|
assert update is None
|
||||||
|
|
@ -139,8 +162,166 @@ async def test_raises_version_update_error(
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(VersionUpdateError) as excinfo:
|
with pytest.raises(VersionUpdateError) as excinfo:
|
||||||
await is_version_update_available(
|
await get_update_if_available(
|
||||||
version_update_notifier, current_version="1.0.0"
|
version_update_notifier,
|
||||||
|
current_version="1.0.0",
|
||||||
|
update_cache_repository=FakeUpdateCacheRepository(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert expected_message_substring in str(excinfo.value)
|
assert expected_message_substring in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_notifies_and_updates_cache_when_repository_is_empty(
|
||||||
|
current_timestamp: int,
|
||||||
|
) -> None:
|
||||||
|
version_update_notifier = FakeVersionUpdateGateway(
|
||||||
|
update=VersionUpdate(latest_version="1.0.1")
|
||||||
|
)
|
||||||
|
update_cache_repository = FakeUpdateCacheRepository()
|
||||||
|
|
||||||
|
update = await get_update_if_available(
|
||||||
|
version_update_notifier,
|
||||||
|
current_version="1.0.0",
|
||||||
|
update_cache_repository=update_cache_repository,
|
||||||
|
get_current_timestamp=lambda: current_timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert update is not None
|
||||||
|
assert update.latest_version == "1.0.1"
|
||||||
|
assert update.should_notify is True
|
||||||
|
assert version_update_notifier.fetch_update_calls == 1
|
||||||
|
assert update_cache_repository.update_cache is not None
|
||||||
|
assert update_cache_repository.update_cache.latest_version == "1.0.1"
|
||||||
|
assert update_cache_repository.update_cache.stored_at_timestamp == current_timestamp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_does_not_notify_when_an_available_update_has_been_recently_cached(
|
||||||
|
current_timestamp: int,
|
||||||
|
) -> None:
|
||||||
|
version_update_notifier = FakeVersionUpdateGateway(
|
||||||
|
update=VersionUpdate(latest_version="1.0.1")
|
||||||
|
)
|
||||||
|
timestamp_twelve_hours_ago = current_timestamp - 12 * 60 * 60
|
||||||
|
update_cache = UpdateCache(
|
||||||
|
latest_version="1.0.1", stored_at_timestamp=timestamp_twelve_hours_ago
|
||||||
|
)
|
||||||
|
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
|
||||||
|
|
||||||
|
update = await get_update_if_available(
|
||||||
|
version_update_notifier,
|
||||||
|
current_version="1.0.0",
|
||||||
|
update_cache_repository=update_cache_repository,
|
||||||
|
get_current_timestamp=lambda: current_timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert update is not None
|
||||||
|
assert update.latest_version == "1.0.1"
|
||||||
|
assert update.should_notify is False
|
||||||
|
assert version_update_notifier.fetch_update_calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retrieves_nothing_when_the_recently_cached_update_is_the_one_currently_in_use(
|
||||||
|
current_timestamp: int,
|
||||||
|
) -> None:
|
||||||
|
version_update_notifier = FakeVersionUpdateGateway(
|
||||||
|
update=VersionUpdate(latest_version="1.0.1")
|
||||||
|
)
|
||||||
|
timestamp_twelve_hours_ago = current_timestamp - 12 * 60 * 60
|
||||||
|
update_cache = UpdateCache(
|
||||||
|
latest_version="1.0.1", stored_at_timestamp=timestamp_twelve_hours_ago
|
||||||
|
)
|
||||||
|
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
|
||||||
|
|
||||||
|
update = await get_update_if_available(
|
||||||
|
version_update_notifier,
|
||||||
|
current_version="1.0.1",
|
||||||
|
update_cache_repository=update_cache_repository,
|
||||||
|
get_current_timestamp=lambda: current_timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert update is None
|
||||||
|
assert version_update_notifier.fetch_update_calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retrieves_fresh_update_and_notifies_and_updates_cache_when_cache_is_not_fresh(
|
||||||
|
current_timestamp: int,
|
||||||
|
) -> None:
|
||||||
|
version_update_notifier = FakeVersionUpdateGateway(
|
||||||
|
update=VersionUpdate(latest_version="1.0.2")
|
||||||
|
)
|
||||||
|
timestamp_two_days_ago = current_timestamp - 48 * 60 * 60
|
||||||
|
update_cache = UpdateCache(
|
||||||
|
latest_version="1.0.1", stored_at_timestamp=timestamp_two_days_ago
|
||||||
|
)
|
||||||
|
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
|
||||||
|
|
||||||
|
update = await get_update_if_available(
|
||||||
|
version_update_notifier,
|
||||||
|
current_version="1.0.0",
|
||||||
|
update_cache_repository=update_cache_repository,
|
||||||
|
get_current_timestamp=lambda: current_timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert update is not None
|
||||||
|
assert version_update_notifier.fetch_update_calls == 1
|
||||||
|
assert update.should_notify is True
|
||||||
|
assert update.latest_version == "1.0.2"
|
||||||
|
assert update_cache_repository.update_cache is not None
|
||||||
|
assert update_cache_repository.update_cache.stored_at_timestamp == current_timestamp
|
||||||
|
assert update_cache_repository.update_cache.latest_version == "1.0.2"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_updates_cache_timestamp_with_current_version_when_no_update_is_available(
|
||||||
|
current_timestamp: int,
|
||||||
|
) -> None:
|
||||||
|
version_update_notifier = FakeVersionUpdateGateway(update=None)
|
||||||
|
timestamp_two_days_ago = current_timestamp - 48 * 60 * 60
|
||||||
|
update_cache = UpdateCache(
|
||||||
|
latest_version="1.0.0", stored_at_timestamp=timestamp_two_days_ago
|
||||||
|
)
|
||||||
|
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
|
||||||
|
|
||||||
|
update = await get_update_if_available(
|
||||||
|
version_update_notifier,
|
||||||
|
current_version="1.0.0",
|
||||||
|
update_cache_repository=update_cache_repository,
|
||||||
|
get_current_timestamp=lambda: current_timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert update is None
|
||||||
|
assert version_update_notifier.fetch_update_calls == 1
|
||||||
|
assert update_cache_repository.update_cache is not None
|
||||||
|
assert update_cache_repository.update_cache.latest_version == "1.0.0"
|
||||||
|
assert update_cache_repository.update_cache.stored_at_timestamp == current_timestamp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_updates_cache_timestamp_with_current_version_when_gateway_errors(
|
||||||
|
current_timestamp: int,
|
||||||
|
) -> None:
|
||||||
|
version_update_notifier = FakeVersionUpdateGateway(
|
||||||
|
error=VersionUpdateGatewayError(cause=VersionUpdateGatewayCause.ERROR_RESPONSE)
|
||||||
|
)
|
||||||
|
timestamp_two_days_ago = current_timestamp - 48 * 60 * 60
|
||||||
|
update_cache = UpdateCache(
|
||||||
|
latest_version="1.0.0", stored_at_timestamp=timestamp_two_days_ago
|
||||||
|
)
|
||||||
|
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
|
||||||
|
|
||||||
|
with pytest.raises(VersionUpdateError):
|
||||||
|
await get_update_if_available(
|
||||||
|
version_update_notifier,
|
||||||
|
current_version="1.0.0",
|
||||||
|
update_cache_repository=update_cache_repository,
|
||||||
|
get_current_timestamp=lambda: current_timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert version_update_notifier.fetch_update_calls == 1
|
||||||
|
assert update_cache_repository.update_cache is not None
|
||||||
|
assert update_cache_repository.update_cache.latest_version == "1.0.0"
|
||||||
|
assert update_cache_repository.update_cache.stored_at_timestamp == current_timestamp
|
||||||
|
|
|
||||||
8
uv.lock
generated
8
uv.lock
generated
|
|
@ -661,7 +661,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "1.1.2"
|
version = "1.1.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "agent-client-protocol" },
|
{ name = "agent-client-protocol" },
|
||||||
|
|
@ -683,9 +683,11 @@ dependencies = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
|
build = [
|
||||||
|
{ name = "pyinstaller" },
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pre-commit" },
|
{ name = "pre-commit" },
|
||||||
{ name = "pyinstaller" },
|
|
||||||
{ name = "pyright" },
|
{ name = "pyright" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
|
|
@ -720,9 +722,9 @@ requires-dist = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
|
build = [{ name = "pyinstaller", specifier = ">=6.17.0" }]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pre-commit", specifier = ">=4.2.0" },
|
{ name = "pre-commit", specifier = ">=4.2.0" },
|
||||||
{ name = "pyinstaller", specifier = ">=6.17.0" },
|
|
||||||
{ name = "pyright", specifier = ">=1.1.403" },
|
{ name = "pyright", specifier = ">=1.1.403" },
|
||||||
{ name = "pytest", specifier = ">=8.3.5" },
|
{ name = "pytest", specifier = ">=8.3.5" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
|
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,9 @@ from vibe.core import __version__
|
||||||
from vibe.core.agent import Agent as VibeAgent
|
from vibe.core.agent import Agent as VibeAgent
|
||||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||||
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_api_keys_from_env
|
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_api_keys_from_env
|
||||||
|
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||||
from vibe.core.types import (
|
from vibe.core.types import (
|
||||||
|
ApprovalResponse,
|
||||||
AssistantEvent,
|
AssistantEvent,
|
||||||
AsyncApprovalCallback,
|
AsyncApprovalCallback,
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
|
|
@ -211,10 +213,32 @@ class VibeAcpAgent(AcpAgent):
|
||||||
return disabled
|
return disabled
|
||||||
|
|
||||||
def _create_approval_callback(self, session_id: str) -> AsyncApprovalCallback:
|
def _create_approval_callback(self, session_id: str) -> AsyncApprovalCallback:
|
||||||
|
session = self._get_session(session_id)
|
||||||
|
|
||||||
|
def _handle_permission_selection(
|
||||||
|
option_id: str, tool_name: str
|
||||||
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
|
match option_id:
|
||||||
|
case ToolOption.ALLOW_ONCE:
|
||||||
|
return (ApprovalResponse.YES, None)
|
||||||
|
case ToolOption.ALLOW_ALWAYS:
|
||||||
|
if tool_name not in session.agent.config.tools:
|
||||||
|
session.agent.config.tools[tool_name] = BaseToolConfig()
|
||||||
|
session.agent.config.tools[
|
||||||
|
tool_name
|
||||||
|
].permission = ToolPermission.ALWAYS
|
||||||
|
return (ApprovalResponse.YES, None)
|
||||||
|
case ToolOption.REJECT_ONCE:
|
||||||
|
return (
|
||||||
|
ApprovalResponse.NO,
|
||||||
|
"User rejected the tool call, provide an alternative plan",
|
||||||
|
)
|
||||||
|
case _:
|
||||||
|
return (ApprovalResponse.NO, f"Unknown option: {option_id}")
|
||||||
|
|
||||||
async def approval_callback(
|
async def approval_callback(
|
||||||
tool_name: str, args: dict[str, Any], tool_call_id: str
|
tool_name: str, args: dict[str, Any], tool_call_id: str
|
||||||
) -> tuple[str, str | None]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
# Create the tool call update
|
# Create the tool call update
|
||||||
tool_call = ToolCall(toolCallId=tool_call_id)
|
tool_call = ToolCall(toolCallId=tool_call_id)
|
||||||
|
|
||||||
|
|
@ -228,10 +252,10 @@ class VibeAcpAgent(AcpAgent):
|
||||||
# Parse the response using isinstance for proper type narrowing
|
# Parse the response using isinstance for proper type narrowing
|
||||||
if response.outcome.outcome == "selected":
|
if response.outcome.outcome == "selected":
|
||||||
outcome = cast(AllowedOutcome, response.outcome)
|
outcome = cast(AllowedOutcome, response.outcome)
|
||||||
return self._handle_permission_selection(outcome.optionId)
|
return _handle_permission_selection(outcome.optionId, tool_name)
|
||||||
else:
|
else:
|
||||||
return (
|
return (
|
||||||
"n",
|
ApprovalResponse.NO,
|
||||||
str(
|
str(
|
||||||
get_user_cancellation_message(
|
get_user_cancellation_message(
|
||||||
CancellationReason.OPERATION_CANCELLED
|
CancellationReason.OPERATION_CANCELLED
|
||||||
|
|
@ -241,18 +265,6 @@ class VibeAcpAgent(AcpAgent):
|
||||||
|
|
||||||
return approval_callback
|
return approval_callback
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _handle_permission_selection(option_id: str) -> tuple[str, str | None]:
|
|
||||||
match option_id:
|
|
||||||
case ToolOption.ALLOW_ONCE:
|
|
||||||
return ("y", None)
|
|
||||||
case ToolOption.ALLOW_ALWAYS:
|
|
||||||
return ("a", None)
|
|
||||||
case ToolOption.REJECT_ONCE:
|
|
||||||
return ("n", "User rejected the tool call, provide an alternative plan")
|
|
||||||
case _:
|
|
||||||
return ("n", f"Unknown option: {option_id}")
|
|
||||||
|
|
||||||
def _get_session(self, session_id: str) -> AcpSession:
|
def _get_session(self, session_id: str) -> AcpSession:
|
||||||
if session_id not in self.sessions:
|
if session_id not in self.sessions:
|
||||||
raise RequestError.invalid_params({"session": "Not found"})
|
raise RequestError.invalid_params({"session": "Not found"})
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,31 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
|
||||||
import pyperclip
|
import pyperclip
|
||||||
from textual.app import App
|
from textual.app import App
|
||||||
|
|
||||||
|
_PREVIEW_MAX_LENGTH = 40
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_osc52(text: str) -> None:
|
||||||
|
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
||||||
|
osc52_seq = f"\033]52;c;{encoded}\a"
|
||||||
|
if os.environ.get("TMUX"):
|
||||||
|
osc52_seq = f"\033Ptmux;\033{osc52_seq}\033\\"
|
||||||
|
|
||||||
|
with open("/dev/tty", "w") as tty:
|
||||||
|
tty.write(osc52_seq)
|
||||||
|
tty.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _shorten_preview(texts: list[str]) -> str:
|
||||||
|
dense_text = "⏎".join(texts).replace("\n", "⏎")
|
||||||
|
if len(dense_text) > _PREVIEW_MAX_LENGTH:
|
||||||
|
return f"{dense_text[: _PREVIEW_MAX_LENGTH - 1]}…"
|
||||||
|
return dense_text
|
||||||
|
|
||||||
|
|
||||||
def copy_selection_to_clipboard(app: App) -> None:
|
def copy_selection_to_clipboard(app: App) -> None:
|
||||||
selected_texts = []
|
selected_texts = []
|
||||||
|
|
@ -30,10 +53,21 @@ def copy_selection_to_clipboard(app: App) -> None:
|
||||||
|
|
||||||
combined_text = "\n".join(selected_texts)
|
combined_text = "\n".join(selected_texts)
|
||||||
|
|
||||||
try:
|
for copy_fn in [_copy_osc52, pyperclip.copy, app.copy_to_clipboard]:
|
||||||
pyperclip.copy(combined_text)
|
try:
|
||||||
app.notify("Selection added to clipboard", severity="information", timeout=2)
|
copy_fn(combined_text)
|
||||||
except Exception:
|
except:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
app.notify(
|
||||||
|
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
|
||||||
|
severity="information",
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
else:
|
||||||
app.notify(
|
app.notify(
|
||||||
"Use Ctrl+c to copy selections in Vibe", severity="warning", timeout=3
|
"Failed to copy - no clipboard method available",
|
||||||
|
severity="warning",
|
||||||
|
timeout=3,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,12 @@ from rich import print as rprint
|
||||||
|
|
||||||
from vibe.cli.textual_ui.app import run_textual_ui
|
from vibe.cli.textual_ui.app import run_textual_ui
|
||||||
from vibe.core.config import (
|
from vibe.core.config import (
|
||||||
CONFIG_FILE,
|
|
||||||
HISTORY_FILE,
|
|
||||||
INSTRUCTIONS_FILE,
|
|
||||||
MissingAPIKeyError,
|
MissingAPIKeyError,
|
||||||
MissingPromptFileError,
|
MissingPromptFileError,
|
||||||
VibeConfig,
|
VibeConfig,
|
||||||
load_api_keys_from_env,
|
load_api_keys_from_env,
|
||||||
)
|
)
|
||||||
|
from vibe.core.config_path import CONFIG_FILE, HISTORY_FILE, INSTRUCTIONS_FILE
|
||||||
from vibe.core.interaction_logger import InteractionLogger
|
from vibe.core.interaction_logger import InteractionLogger
|
||||||
from vibe.core.programmatic import run_programmatic
|
from vibe.core.programmatic import run_programmatic
|
||||||
from vibe.core.types import OutputFormat, ResumeSessionInfo
|
from vibe.core.types import OutputFormat, ResumeSessionInfo
|
||||||
|
|
@ -138,23 +136,23 @@ def main() -> None: # noqa: PLR0912, PLR0915
|
||||||
run_onboarding()
|
run_onboarding()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
try:
|
try:
|
||||||
if not CONFIG_FILE.exists():
|
if not CONFIG_FILE.path.exists():
|
||||||
try:
|
try:
|
||||||
VibeConfig.save_updates(VibeConfig.create_default())
|
VibeConfig.save_updates(VibeConfig.create_default())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
rprint(f"[yellow]Could not create default config file: {e}[/]")
|
rprint(f"[yellow]Could not create default config file: {e}[/]")
|
||||||
|
|
||||||
if not INSTRUCTIONS_FILE.exists():
|
if not INSTRUCTIONS_FILE.path.exists():
|
||||||
try:
|
try:
|
||||||
INSTRUCTIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
INSTRUCTIONS_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
INSTRUCTIONS_FILE.touch()
|
INSTRUCTIONS_FILE.path.touch()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
rprint(f"[yellow]Could not create instructions file: {e}[/]")
|
rprint(f"[yellow]Could not create instructions file: {e}[/]")
|
||||||
|
|
||||||
if not HISTORY_FILE.exists():
|
if not HISTORY_FILE.path.exists():
|
||||||
try:
|
try:
|
||||||
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
HISTORY_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
HISTORY_FILE.write_text("Hello Vibe!\n", "utf-8")
|
HISTORY_FILE.path.write_text("Hello Vibe!\n", "utf-8")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
rprint(f"[yellow]Could not create history file: {e}[/]")
|
rprint(f"[yellow]Could not create history file: {e}[/]")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from enum import StrEnum, auto
|
from enum import StrEnum, auto
|
||||||
import os
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Any, ClassVar, assert_never
|
from typing import Any, ClassVar, assert_never
|
||||||
|
|
||||||
|
|
@ -35,20 +34,22 @@ from vibe.cli.textual_ui.widgets.path_display import PathDisplay
|
||||||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
||||||
from vibe.cli.textual_ui.widgets.welcome import WelcomeBanner
|
from vibe.cli.textual_ui.widgets.welcome import WelcomeBanner
|
||||||
from vibe.cli.update_notifier import (
|
from vibe.cli.update_notifier import (
|
||||||
GitHubVersionUpdateGateway,
|
FileSystemUpdateCacheRepository,
|
||||||
VersionUpdate,
|
PyPIVersionUpdateGateway,
|
||||||
|
UpdateCacheRepository,
|
||||||
|
VersionUpdateAvailability,
|
||||||
VersionUpdateError,
|
VersionUpdateError,
|
||||||
is_version_update_available,
|
VersionUpdateGateway,
|
||||||
|
get_update_if_available,
|
||||||
)
|
)
|
||||||
from vibe.cli.update_notifier.version_update_gateway import VersionUpdateGateway
|
|
||||||
from vibe.core import __version__ as CORE_VERSION
|
from vibe.core import __version__ as CORE_VERSION
|
||||||
from vibe.core.agent import Agent
|
from vibe.core.agent import Agent
|
||||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||||
from vibe.core.config import HISTORY_FILE, VibeConfig
|
from vibe.core.config import VibeConfig
|
||||||
|
from vibe.core.config_path import HISTORY_FILE
|
||||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||||
from vibe.core.types import LLMMessage, ResumeSessionInfo, Role
|
from vibe.core.types import ApprovalResponse, LLMMessage, ResumeSessionInfo, Role
|
||||||
from vibe.core.utils import (
|
from vibe.core.utils import (
|
||||||
ApprovalResponse,
|
|
||||||
CancellationReason,
|
CancellationReason,
|
||||||
get_user_cancellation_message,
|
get_user_cancellation_message,
|
||||||
is_dangerous_directory,
|
is_dangerous_directory,
|
||||||
|
|
@ -72,6 +73,10 @@ class VibeApp(App):
|
||||||
Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False),
|
Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False),
|
||||||
Binding("ctrl+t", "toggle_todo", "Toggle Todo", show=False),
|
Binding("ctrl+t", "toggle_todo", "Toggle Todo", show=False),
|
||||||
Binding("shift+tab", "cycle_mode", "Cycle Mode", show=False, priority=True),
|
Binding("shift+tab", "cycle_mode", "Cycle Mode", show=False, priority=True),
|
||||||
|
Binding("shift+up", "scroll_chat_up", "Scroll Up", show=False, priority=True),
|
||||||
|
Binding(
|
||||||
|
"shift+down", "scroll_chat_down", "Scroll Down", show=False, priority=True
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -83,6 +88,7 @@ class VibeApp(App):
|
||||||
loaded_messages: list[LLMMessage] | None = None,
|
loaded_messages: list[LLMMessage] | None = None,
|
||||||
session_info: ResumeSessionInfo | None = None,
|
session_info: ResumeSessionInfo | None = None,
|
||||||
version_update_notifier: VersionUpdateGateway | None = None,
|
version_update_notifier: VersionUpdateGateway | None = None,
|
||||||
|
update_cache_repository: UpdateCacheRepository | None = None,
|
||||||
current_version: str = CORE_VERSION,
|
current_version: str = CORE_VERSION,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -108,12 +114,13 @@ class VibeApp(App):
|
||||||
self._current_bottom_app: BottomApp = BottomApp.Input
|
self._current_bottom_app: BottomApp = BottomApp.Input
|
||||||
self.theme = config.textual_theme
|
self.theme = config.textual_theme
|
||||||
|
|
||||||
self.history_file = HISTORY_FILE
|
self.history_file = HISTORY_FILE.path
|
||||||
|
|
||||||
self._tools_collapsed = True
|
self._tools_collapsed = True
|
||||||
self._todos_collapsed = False
|
self._todos_collapsed = False
|
||||||
self._current_streaming_message: AssistantMessage | None = None
|
self._current_streaming_message: AssistantMessage | None = None
|
||||||
self._version_update_notifier = version_update_notifier
|
self._version_update_notifier = version_update_notifier
|
||||||
|
self._update_cache_repository = update_cache_repository
|
||||||
self._is_update_check_enabled = config.enable_update_checks
|
self._is_update_check_enabled = config.enable_update_checks
|
||||||
self._current_version = current_version
|
self._current_version = current_version
|
||||||
self._update_notification_task: asyncio.Task | None = None
|
self._update_notification_task: asyncio.Task | None = None
|
||||||
|
|
@ -126,6 +133,7 @@ class VibeApp(App):
|
||||||
# prevent a race condition where the agent initialization
|
# prevent a race condition where the agent initialization
|
||||||
# completes exactly at the moment the user interrupts
|
# completes exactly at the moment the user interrupts
|
||||||
self._agent_init_interrupted = False
|
self._agent_init_interrupted = False
|
||||||
|
self._auto_scroll = True
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with VerticalScroll(id="chat"):
|
with VerticalScroll(id="chat"):
|
||||||
|
|
@ -460,7 +468,7 @@ class VibeApp(App):
|
||||||
|
|
||||||
async def _approval_callback(
|
async def _approval_callback(
|
||||||
self, tool: str, args: dict, tool_call_id: str
|
self, tool: str, args: dict, tool_call_id: str
|
||||||
) -> tuple[str, str | None]:
|
) -> tuple[ApprovalResponse, str | None]:
|
||||||
self._pending_approval = asyncio.Future()
|
self._pending_approval = asyncio.Future()
|
||||||
await self._switch_to_approval_app(tool, args)
|
await self._switch_to_approval_app(tool, args)
|
||||||
result = await self._pending_approval
|
result = await self._pending_approval
|
||||||
|
|
@ -955,6 +963,23 @@ class VibeApp(App):
|
||||||
|
|
||||||
self.exit()
|
self.exit()
|
||||||
|
|
||||||
|
def action_scroll_chat_up(self) -> None:
|
||||||
|
try:
|
||||||
|
chat = self.query_one("#chat", VerticalScroll)
|
||||||
|
chat.scroll_relative(y=-5, animate=False)
|
||||||
|
self._auto_scroll = False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def action_scroll_chat_down(self) -> None:
|
||||||
|
try:
|
||||||
|
chat = self.query_one("#chat", VerticalScroll)
|
||||||
|
chat.scroll_relative(y=5, animate=False)
|
||||||
|
if self._is_scrolled_to_bottom(chat):
|
||||||
|
self._auto_scroll = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def _show_dangerous_directory_warning(self) -> None:
|
async def _show_dangerous_directory_warning(self) -> None:
|
||||||
is_dangerous, reason = is_dangerous_directory()
|
is_dangerous, reason = is_dangerous_directory()
|
||||||
if is_dangerous:
|
if is_dangerous:
|
||||||
|
|
@ -975,6 +1000,9 @@ class VibeApp(App):
|
||||||
chat = self.query_one("#chat", VerticalScroll)
|
chat = self.query_one("#chat", VerticalScroll)
|
||||||
was_at_bottom = self._is_scrolled_to_bottom(chat)
|
was_at_bottom = self._is_scrolled_to_bottom(chat)
|
||||||
|
|
||||||
|
if was_at_bottom:
|
||||||
|
self._auto_scroll = True
|
||||||
|
|
||||||
if isinstance(widget, AssistantMessage):
|
if isinstance(widget, AssistantMessage):
|
||||||
if self._current_streaming_message is not None:
|
if self._current_streaming_message is not None:
|
||||||
content = widget._content or ""
|
content = widget._content or ""
|
||||||
|
|
@ -1014,6 +1042,8 @@ class VibeApp(App):
|
||||||
self.call_after_refresh(self._scroll_to_bottom)
|
self.call_after_refresh(self._scroll_to_bottom)
|
||||||
|
|
||||||
def _anchor_if_scrollable(self) -> None:
|
def _anchor_if_scrollable(self) -> None:
|
||||||
|
if not self._auto_scroll:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
chat = self.query_one("#chat", VerticalScroll)
|
chat = self.query_one("#chat", VerticalScroll)
|
||||||
if chat.max_scroll_y == 0:
|
if chat.max_scroll_y == 0:
|
||||||
|
|
@ -1036,11 +1066,16 @@ class VibeApp(App):
|
||||||
|
|
||||||
async def _check_version_update(self) -> None:
|
async def _check_version_update(self) -> None:
|
||||||
try:
|
try:
|
||||||
if self._version_update_notifier is None:
|
if (
|
||||||
|
self._version_update_notifier is None
|
||||||
|
or self._update_cache_repository is None
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
update = await is_version_update_available(
|
update = await get_update_if_available(
|
||||||
self._version_update_notifier, current_version=self._current_version
|
version_update_notifier=self._version_update_notifier,
|
||||||
|
current_version=self._current_version,
|
||||||
|
update_cache_repository=self._update_cache_repository,
|
||||||
)
|
)
|
||||||
except VersionUpdateError as error:
|
except VersionUpdateError as error:
|
||||||
self.notify(
|
self.notify(
|
||||||
|
|
@ -1056,12 +1091,12 @@ class VibeApp(App):
|
||||||
finally:
|
finally:
|
||||||
self._update_notification_task = None
|
self._update_notification_task = None
|
||||||
|
|
||||||
if update is None:
|
if update is None or not update.should_notify:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._display_update_notification(update)
|
self._display_update_notification(update)
|
||||||
|
|
||||||
def _display_update_notification(self, update: VersionUpdate) -> None:
|
def _display_update_notification(self, update: VersionUpdateAvailability) -> None:
|
||||||
if self._update_notification_shown:
|
if self._update_notification_shown:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -1084,9 +1119,8 @@ def run_textual_ui(
|
||||||
loaded_messages: list[LLMMessage] | None = None,
|
loaded_messages: list[LLMMessage] | None = None,
|
||||||
session_info: ResumeSessionInfo | None = None,
|
session_info: ResumeSessionInfo | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
update_notifier = GitHubVersionUpdateGateway(
|
update_notifier = PyPIVersionUpdateGateway(project_name="mistral-vibe")
|
||||||
owner="mistralai", repository="mistral-vibe", token=os.getenv("GITHUB_TOKEN")
|
update_cache_repository = FileSystemUpdateCacheRepository()
|
||||||
)
|
|
||||||
app = VibeApp(
|
app = VibeApp(
|
||||||
config=config,
|
config=config,
|
||||||
auto_approve=auto_approve,
|
auto_approve=auto_approve,
|
||||||
|
|
@ -1095,5 +1129,6 @@ def run_textual_ui(
|
||||||
loaded_messages=loaded_messages,
|
loaded_messages=loaded_messages,
|
||||||
session_info=session_info,
|
session_info=session_info,
|
||||||
version_update_notifier=update_notifier,
|
version_update_notifier=update_notifier,
|
||||||
|
update_cache_repository=update_cache_repository,
|
||||||
)
|
)
|
||||||
app.run()
|
app.run()
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,43 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from vibe.cli.update_notifier.fake_version_update_gateway import (
|
from vibe.cli.update_notifier.adapters.filesystem_update_cache_repository import (
|
||||||
FakeVersionUpdateGateway,
|
FileSystemUpdateCacheRepository,
|
||||||
)
|
)
|
||||||
from vibe.cli.update_notifier.github_version_update_gateway import (
|
from vibe.cli.update_notifier.adapters.github_version_update_gateway import (
|
||||||
GitHubVersionUpdateGateway,
|
GitHubVersionUpdateGateway,
|
||||||
)
|
)
|
||||||
from vibe.cli.update_notifier.version_update import (
|
from vibe.cli.update_notifier.adapters.pypi_version_update_gateway import (
|
||||||
VersionUpdateError,
|
PyPIVersionUpdateGateway,
|
||||||
is_version_update_available,
|
|
||||||
)
|
)
|
||||||
from vibe.cli.update_notifier.version_update_gateway import (
|
from vibe.cli.update_notifier.ports.update_cache_repository import (
|
||||||
|
UpdateCache,
|
||||||
|
UpdateCacheRepository,
|
||||||
|
)
|
||||||
|
from vibe.cli.update_notifier.ports.version_update_gateway import (
|
||||||
DEFAULT_GATEWAY_MESSAGES,
|
DEFAULT_GATEWAY_MESSAGES,
|
||||||
VersionUpdate,
|
VersionUpdate,
|
||||||
VersionUpdateGateway,
|
VersionUpdateGateway,
|
||||||
VersionUpdateGatewayCause,
|
VersionUpdateGatewayCause,
|
||||||
VersionUpdateGatewayError,
|
VersionUpdateGatewayError,
|
||||||
)
|
)
|
||||||
|
from vibe.cli.update_notifier.version_update import (
|
||||||
|
VersionUpdateAvailability,
|
||||||
|
VersionUpdateError,
|
||||||
|
get_update_if_available,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_GATEWAY_MESSAGES",
|
"DEFAULT_GATEWAY_MESSAGES",
|
||||||
"FakeVersionUpdateGateway",
|
"FileSystemUpdateCacheRepository",
|
||||||
"GitHubVersionUpdateGateway",
|
"GitHubVersionUpdateGateway",
|
||||||
|
"PyPIVersionUpdateGateway",
|
||||||
|
"UpdateCache",
|
||||||
|
"UpdateCacheRepository",
|
||||||
"VersionUpdate",
|
"VersionUpdate",
|
||||||
|
"VersionUpdateAvailability",
|
||||||
"VersionUpdateError",
|
"VersionUpdateError",
|
||||||
"VersionUpdateGateway",
|
"VersionUpdateGateway",
|
||||||
"VersionUpdateGatewayCause",
|
"VersionUpdateGatewayCause",
|
||||||
"VersionUpdateGatewayError",
|
"VersionUpdateGatewayError",
|
||||||
"is_version_update_available",
|
"get_update_if_available",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from vibe.cli.update_notifier.ports.update_cache_repository import (
|
||||||
|
UpdateCache,
|
||||||
|
UpdateCacheRepository,
|
||||||
|
)
|
||||||
|
from vibe.core.config_path import VIBE_HOME
|
||||||
|
|
||||||
|
|
||||||
|
class FileSystemUpdateCacheRepository(UpdateCacheRepository):
|
||||||
|
def __init__(self, base_path: Path | str | None = None) -> None:
|
||||||
|
self._base_path = Path(base_path) if base_path is not None else VIBE_HOME.path
|
||||||
|
self._cache_file = self._base_path / "update_cache.json"
|
||||||
|
|
||||||
|
async def get(self) -> UpdateCache | None:
|
||||||
|
try:
|
||||||
|
content = await asyncio.to_thread(self._cache_file.read_text)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(content)
|
||||||
|
latest_version = data.get("latest_version")
|
||||||
|
stored_at_timestamp = data.get("stored_at_timestamp")
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(latest_version, str) or not isinstance(
|
||||||
|
stored_at_timestamp, int
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return UpdateCache(
|
||||||
|
latest_version=latest_version, stored_at_timestamp=stored_at_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set(self, update_cache: UpdateCache) -> None:
|
||||||
|
try:
|
||||||
|
payload = json.dumps({
|
||||||
|
"latest_version": update_cache.latest_version,
|
||||||
|
"stored_at_timestamp": update_cache.stored_at_timestamp,
|
||||||
|
})
|
||||||
|
await asyncio.to_thread(self._cache_file.write_text, payload)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from vibe.cli.update_notifier.version_update_gateway import (
|
from vibe.cli.update_notifier.ports.version_update_gateway import (
|
||||||
VersionUpdate,
|
VersionUpdate,
|
||||||
VersionUpdateGateway,
|
VersionUpdateGateway,
|
||||||
VersionUpdateGatewayCause,
|
VersionUpdateGatewayCause,
|
||||||
113
vibe/cli/update_notifier/adapters/pypi_version_update_gateway.py
Normal file
113
vibe/cli/update_notifier/adapters/pypi_version_update_gateway.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from packaging.utils import parse_sdist_filename, parse_wheel_filename
|
||||||
|
from packaging.version import InvalidVersion, Version
|
||||||
|
|
||||||
|
from vibe.cli.update_notifier.ports.version_update_gateway import (
|
||||||
|
VersionUpdate,
|
||||||
|
VersionUpdateGateway,
|
||||||
|
VersionUpdateGatewayCause,
|
||||||
|
VersionUpdateGatewayError,
|
||||||
|
)
|
||||||
|
|
||||||
|
_STATUS_CAUSES: dict[int, VersionUpdateGatewayCause] = {
|
||||||
|
httpx.codes.NOT_FOUND: VersionUpdateGatewayCause.NOT_FOUND,
|
||||||
|
httpx.codes.FORBIDDEN: VersionUpdateGatewayCause.FORBIDDEN,
|
||||||
|
httpx.codes.TOO_MANY_REQUESTS: VersionUpdateGatewayCause.TOO_MANY_REQUESTS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PyPIVersionUpdateGateway(VersionUpdateGateway):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
project_name: str,
|
||||||
|
*,
|
||||||
|
client: httpx.AsyncClient | None = None,
|
||||||
|
timeout: float = 5.0,
|
||||||
|
base_url: str = "https://pypi.org",
|
||||||
|
) -> None:
|
||||||
|
self._project_name = project_name
|
||||||
|
self._client = client
|
||||||
|
self._timeout = timeout
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
|
||||||
|
async def fetch_update(self) -> VersionUpdate | None:
|
||||||
|
response = await self._fetch()
|
||||||
|
self._raise_gateway_error_if_any(response)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise VersionUpdateGatewayError(
|
||||||
|
cause=VersionUpdateGatewayCause.INVALID_RESPONSE
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
versions = data.get("versions") or []
|
||||||
|
files = data.get("files") or []
|
||||||
|
|
||||||
|
non_yanked_versions: set[Version] = set()
|
||||||
|
for file in files:
|
||||||
|
if not isinstance(file, dict) or file.get("yanked") is True:
|
||||||
|
continue
|
||||||
|
filename = file.get("filename")
|
||||||
|
if not isinstance(filename, str):
|
||||||
|
continue
|
||||||
|
parsed_version = _parse_filename_version(filename)
|
||||||
|
if parsed_version is not None:
|
||||||
|
non_yanked_versions.add(parsed_version)
|
||||||
|
|
||||||
|
valid_versions: list[Version] = []
|
||||||
|
for raw_version in versions:
|
||||||
|
try:
|
||||||
|
valid_versions.append(Version(str(raw_version)))
|
||||||
|
except InvalidVersion:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for version in sorted(valid_versions, reverse=True):
|
||||||
|
if version in non_yanked_versions:
|
||||||
|
return VersionUpdate(latest_version=str(version))
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _fetch(self) -> httpx.Response:
|
||||||
|
headers = {"Accept": "application/vnd.pypi.simple.v1+json"}
|
||||||
|
request_path = f"/simple/{self._project_name}/"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._client is not None:
|
||||||
|
return await self._client.get(
|
||||||
|
f"{self._base_url}{request_path}",
|
||||||
|
headers=headers,
|
||||||
|
timeout=self._timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=self._base_url, timeout=self._timeout
|
||||||
|
) as client:
|
||||||
|
return await client.get(request_path, headers=headers)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise VersionUpdateGatewayError(
|
||||||
|
cause=VersionUpdateGatewayCause.REQUEST_FAILED
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
def _raise_gateway_error_if_any(self, response: httpx.Response) -> None:
|
||||||
|
if response.status_code in _STATUS_CAUSES:
|
||||||
|
raise VersionUpdateGatewayError(cause=_STATUS_CAUSES[response.status_code])
|
||||||
|
|
||||||
|
if response.is_error:
|
||||||
|
raise VersionUpdateGatewayError(
|
||||||
|
cause=VersionUpdateGatewayCause.ERROR_RESPONSE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_filename_version(filename: str) -> Version | None:
|
||||||
|
try:
|
||||||
|
_, version, *_ = parse_wheel_filename(filename)
|
||||||
|
return Version(str(version))
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
_, sdist_version = parse_sdist_filename(filename)
|
||||||
|
return Version(str(sdist_version))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
15
vibe/cli/update_notifier/ports/update_cache_repository.py
Normal file
15
vibe/cli/update_notifier/ports/update_cache_repository.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class UpdateCache:
|
||||||
|
latest_version: str
|
||||||
|
stored_at_timestamp: int
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCacheRepository(Protocol):
|
||||||
|
async def get(self) -> UpdateCache | None: ...
|
||||||
|
async def set(self, update_cache: UpdateCache) -> None: ...
|
||||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum, auto
|
from enum import StrEnum, auto
|
||||||
from typing import Protocol, runtime_checkable
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|
@ -49,6 +49,5 @@ class VersionUpdateGatewayError(Exception):
|
||||||
super().__init__(detail)
|
super().__init__(detail)
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class VersionUpdateGateway(Protocol):
|
class VersionUpdateGateway(Protocol):
|
||||||
async def fetch_update(self) -> VersionUpdate | None: ...
|
async def fetch_update(self) -> VersionUpdate | None: ...
|
||||||
|
|
@ -1,15 +1,28 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import time
|
||||||
|
|
||||||
from packaging.version import InvalidVersion, Version
|
from packaging.version import InvalidVersion, Version
|
||||||
|
|
||||||
from vibe.cli.update_notifier.version_update_gateway import (
|
from vibe.cli.update_notifier import (
|
||||||
DEFAULT_GATEWAY_MESSAGES,
|
DEFAULT_GATEWAY_MESSAGES,
|
||||||
VersionUpdate,
|
UpdateCache,
|
||||||
|
UpdateCacheRepository,
|
||||||
VersionUpdateGateway,
|
VersionUpdateGateway,
|
||||||
VersionUpdateGatewayCause,
|
VersionUpdateGatewayCause,
|
||||||
VersionUpdateGatewayError,
|
VersionUpdateGatewayError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
UPDATE_CACHE_TTL_SECONDS = 24 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VersionUpdateAvailability:
|
||||||
|
latest_version: str
|
||||||
|
should_notify: bool
|
||||||
|
|
||||||
|
|
||||||
class VersionUpdateError(Exception):
|
class VersionUpdateError(Exception):
|
||||||
def __init__(self, message: str) -> None:
|
def __init__(self, message: str) -> None:
|
||||||
|
|
@ -37,21 +50,76 @@ def _describe_gateway_error(error: VersionUpdateGatewayError) -> str:
|
||||||
return DEFAULT_GATEWAY_MESSAGES[VersionUpdateGatewayCause.UNKNOWN]
|
return DEFAULT_GATEWAY_MESSAGES[VersionUpdateGatewayCause.UNKNOWN]
|
||||||
|
|
||||||
|
|
||||||
async def is_version_update_available(
|
def _is_cache_fresh(
|
||||||
version_update_notifier: VersionUpdateGateway, current_version: str
|
cache: UpdateCache, get_current_timestamp: Callable[[], int]
|
||||||
) -> VersionUpdate | None:
|
) -> bool:
|
||||||
|
return (
|
||||||
|
cache.stored_at_timestamp > get_current_timestamp() - UPDATE_CACHE_TTL_SECONDS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cached_update_if_any(
|
||||||
|
cache: UpdateCache, current: Version
|
||||||
|
) -> VersionUpdateAvailability | None:
|
||||||
|
latest_version_in_cache = _parse_version(cache.latest_version)
|
||||||
|
if latest_version_in_cache is None or latest_version_in_cache <= current:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return VersionUpdateAvailability(
|
||||||
|
latest_version=cache.latest_version, should_notify=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_update_cache(
|
||||||
|
repository: UpdateCacheRepository,
|
||||||
|
version: str,
|
||||||
|
get_current_timestamp: Callable[[], int],
|
||||||
|
) -> None:
|
||||||
|
await repository.set(
|
||||||
|
UpdateCache(latest_version=version, stored_at_timestamp=get_current_timestamp())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_update_if_available(
|
||||||
|
version_update_notifier: VersionUpdateGateway,
|
||||||
|
current_version: str,
|
||||||
|
update_cache_repository: UpdateCacheRepository,
|
||||||
|
get_current_timestamp: Callable[[], int] = lambda: int(time.time()),
|
||||||
|
) -> VersionUpdateAvailability | None:
|
||||||
|
if not (current := _parse_version(current_version)):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if update_cache := await update_cache_repository.get():
|
||||||
|
if _is_cache_fresh(update_cache, get_current_timestamp):
|
||||||
|
return _get_cached_update_if_any(update_cache, current)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
update = await version_update_notifier.fetch_update()
|
update = await version_update_notifier.fetch_update()
|
||||||
except VersionUpdateGatewayError as error:
|
except VersionUpdateGatewayError as error:
|
||||||
|
await _write_update_cache(
|
||||||
|
update_cache_repository, current_version, get_current_timestamp
|
||||||
|
)
|
||||||
raise VersionUpdateError(_describe_gateway_error(error)) from error
|
raise VersionUpdateError(_describe_gateway_error(error)) from error
|
||||||
|
|
||||||
if not update:
|
if not update:
|
||||||
|
await _write_update_cache(
|
||||||
|
update_cache_repository, current_version, get_current_timestamp
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
latest_version = _parse_version(update.latest_version)
|
if not (latest_version := _parse_version(update.latest_version)):
|
||||||
current = _parse_version(current_version)
|
|
||||||
|
|
||||||
if latest_version is None or current is None:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return update if latest_version > current else None
|
if latest_version <= current:
|
||||||
|
await _write_update_cache(
|
||||||
|
update_cache_repository, current_version, get_current_timestamp
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
await _write_update_cache(
|
||||||
|
update_cache_repository, update.latest_version, get_current_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
return VersionUpdateAvailability(
|
||||||
|
latest_version=update.latest_version, should_notify=True
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__all__ = ["__version__", "run_programmatic"]
|
__all__ = ["__version__", "run_programmatic"]
|
||||||
__version__ = "1.1.2"
|
__version__ = "1.1.3"
|
||||||
|
|
||||||
from vibe.core.programmatic import run_programmatic
|
from vibe.core.programmatic import run_programmatic
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ from vibe.core.tools.manager import ToolManager
|
||||||
from vibe.core.types import (
|
from vibe.core.types import (
|
||||||
AgentStats,
|
AgentStats,
|
||||||
ApprovalCallback,
|
ApprovalCallback,
|
||||||
|
ApprovalResponse,
|
||||||
AssistantEvent,
|
AssistantEvent,
|
||||||
|
AsyncApprovalCallback,
|
||||||
BaseEvent,
|
BaseEvent,
|
||||||
CompactEndEvent,
|
CompactEndEvent,
|
||||||
CompactStartEvent,
|
CompactStartEvent,
|
||||||
|
|
@ -53,7 +55,6 @@ from vibe.core.types import (
|
||||||
from vibe.core.utils import (
|
from vibe.core.utils import (
|
||||||
TOOL_ERROR_TAG,
|
TOOL_ERROR_TAG,
|
||||||
VIBE_STOP_EVENT_TAG,
|
VIBE_STOP_EVENT_TAG,
|
||||||
ApprovalResponse,
|
|
||||||
CancellationReason,
|
CancellationReason,
|
||||||
get_user_agent,
|
get_user_agent,
|
||||||
get_user_cancellation_message,
|
get_user_cancellation_message,
|
||||||
|
|
@ -188,11 +189,6 @@ class Agent:
|
||||||
case MiddlewareAction.STOP:
|
case MiddlewareAction.STOP:
|
||||||
yield AssistantEvent(
|
yield AssistantEvent(
|
||||||
content=f"<{VIBE_STOP_EVENT_TAG}>{result.reason}</{VIBE_STOP_EVENT_TAG}>",
|
content=f"<{VIBE_STOP_EVENT_TAG}>{result.reason}</{VIBE_STOP_EVENT_TAG}>",
|
||||||
prompt_tokens=0,
|
|
||||||
completion_tokens=0,
|
|
||||||
session_total_tokens=self.stats.session_total_llm_tokens,
|
|
||||||
last_turn_duration=0,
|
|
||||||
tokens_per_second=0,
|
|
||||||
stopped_by_middleware=True,
|
stopped_by_middleware=True,
|
||||||
)
|
)
|
||||||
await self.interaction_logger.save_interaction(
|
await self.interaction_logger.save_interaction(
|
||||||
|
|
@ -337,16 +333,7 @@ class Agent:
|
||||||
def _create_assistant_event(
|
def _create_assistant_event(
|
||||||
self, content: str, chunk: LLMChunk | None
|
self, content: str, chunk: LLMChunk | None
|
||||||
) -> AssistantEvent:
|
) -> AssistantEvent:
|
||||||
return AssistantEvent(
|
return AssistantEvent(content=content)
|
||||||
content=content,
|
|
||||||
prompt_tokens=chunk.usage.prompt_tokens if chunk and chunk.usage else 0,
|
|
||||||
completion_tokens=chunk.usage.completion_tokens
|
|
||||||
if chunk and chunk.usage
|
|
||||||
else 0,
|
|
||||||
session_total_tokens=self.stats.session_total_llm_tokens,
|
|
||||||
last_turn_duration=self.stats.last_turn_duration,
|
|
||||||
tokens_per_second=self.stats.tokens_per_second,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _stream_assistant_events(self) -> AsyncGenerator[AssistantEvent]:
|
async def _stream_assistant_events(self) -> AsyncGenerator[AssistantEvent]:
|
||||||
chunks: list[LLMChunk] = []
|
chunks: list[LLMChunk] = []
|
||||||
|
|
@ -421,14 +408,7 @@ class Agent:
|
||||||
assistant_msg = llm_result.message
|
assistant_msg = llm_result.message
|
||||||
self.messages.append(assistant_msg)
|
self.messages.append(assistant_msg)
|
||||||
|
|
||||||
return AssistantEvent(
|
return AssistantEvent(content=assistant_msg.content or "")
|
||||||
content=assistant_msg.content or "",
|
|
||||||
prompt_tokens=llm_result.usage.prompt_tokens,
|
|
||||||
completion_tokens=llm_result.usage.completion_tokens,
|
|
||||||
session_total_tokens=self.stats.session_total_llm_tokens,
|
|
||||||
last_turn_duration=self.stats.last_turn_duration,
|
|
||||||
tokens_per_second=self.stats.tokens_per_second,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handle_tool_calls( # noqa: PLR0915
|
async def _handle_tool_calls( # noqa: PLR0915
|
||||||
self, resolved: ResolvedMessage
|
self, resolved: ResolvedMessage
|
||||||
|
|
@ -761,24 +741,18 @@ class Agent:
|
||||||
feedback="Tool execution not permitted.",
|
feedback="Tool execution not permitted.",
|
||||||
)
|
)
|
||||||
if asyncio.iscoroutinefunction(self.approval_callback):
|
if asyncio.iscoroutinefunction(self.approval_callback):
|
||||||
response, feedback = await self.approval_callback(
|
async_callback = cast(AsyncApprovalCallback, self.approval_callback)
|
||||||
tool_name, args, tool_call_id
|
response, feedback = await async_callback(tool_name, args, tool_call_id)
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
sync_callback = cast(SyncApprovalCallback, self.approval_callback)
|
sync_callback = cast(SyncApprovalCallback, self.approval_callback)
|
||||||
response, feedback = sync_callback(tool_name, args, tool_call_id)
|
response, feedback = sync_callback(tool_name, args, tool_call_id)
|
||||||
|
|
||||||
match response:
|
match response:
|
||||||
case ApprovalResponse.ALWAYS:
|
|
||||||
self.auto_approve = True
|
|
||||||
return ToolDecision(
|
|
||||||
verdict=ToolExecutionResponse.EXECUTE, feedback=feedback
|
|
||||||
)
|
|
||||||
case ApprovalResponse.YES:
|
case ApprovalResponse.YES:
|
||||||
return ToolDecision(
|
return ToolDecision(
|
||||||
verdict=ToolExecutionResponse.EXECUTE, feedback=feedback
|
verdict=ToolExecutionResponse.EXECUTE, feedback=feedback
|
||||||
)
|
)
|
||||||
case _:
|
case ApprovalResponse.NO:
|
||||||
return ToolDecision(
|
return ToolDecision(
|
||||||
verdict=ToolExecutionResponse.SKIP, feedback=feedback
|
verdict=ToolExecutionResponse.SKIP, feedback=feedback
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,45 +19,28 @@ from pydantic_settings import (
|
||||||
)
|
)
|
||||||
import tomli_w
|
import tomli_w
|
||||||
|
|
||||||
|
from vibe.core.config_path import (
|
||||||
|
AGENT_DIR,
|
||||||
|
CONFIG_DIR,
|
||||||
|
CONFIG_FILE,
|
||||||
|
GLOBAL_ENV_FILE,
|
||||||
|
PROMPT_DIR,
|
||||||
|
SESSION_LOG_DIR,
|
||||||
|
)
|
||||||
from vibe.core.prompts import SystemPrompt
|
from vibe.core.prompts import SystemPrompt
|
||||||
from vibe.core.tools.base import BaseToolConfig
|
from vibe.core.tools.base import BaseToolConfig
|
||||||
|
|
||||||
|
PROJECT_DOC_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"]
|
||||||
def get_vibe_home() -> Path:
|
|
||||||
if vibe_home := os.getenv("VIBE_HOME"):
|
|
||||||
return Path(vibe_home).expanduser().resolve()
|
|
||||||
return Path.home() / ".vibe"
|
|
||||||
|
|
||||||
|
|
||||||
GLOBAL_CONFIG_DIR = get_vibe_home()
|
|
||||||
GLOBAL_CONFIG_FILE = GLOBAL_CONFIG_DIR / "config.toml"
|
|
||||||
GLOBAL_ENV_FILE = GLOBAL_CONFIG_DIR / ".env"
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_config_file() -> Path:
|
|
||||||
for directory in (cwd := Path.cwd(), *cwd.parents):
|
|
||||||
if (candidate := directory / ".vibe" / "config.toml").is_file():
|
|
||||||
return candidate
|
|
||||||
return GLOBAL_CONFIG_FILE
|
|
||||||
|
|
||||||
|
|
||||||
def load_api_keys_from_env() -> None:
|
def load_api_keys_from_env() -> None:
|
||||||
if GLOBAL_ENV_FILE.is_file():
|
if GLOBAL_ENV_FILE.path.is_file():
|
||||||
env_vars = dotenv_values(GLOBAL_ENV_FILE)
|
env_vars = dotenv_values(GLOBAL_ENV_FILE.path)
|
||||||
for key, value in env_vars.items():
|
for key, value in env_vars.items():
|
||||||
if value:
|
if value:
|
||||||
os.environ.setdefault(key, value)
|
os.environ.setdefault(key, value)
|
||||||
|
|
||||||
|
|
||||||
CONFIG_FILE = resolve_config_file()
|
|
||||||
CONFIG_DIR = CONFIG_FILE.parent
|
|
||||||
AGENT_DIR = CONFIG_DIR / "agents"
|
|
||||||
PROMPT_DIR = CONFIG_DIR / "prompts"
|
|
||||||
INSTRUCTIONS_FILE = CONFIG_DIR / "instructions.md"
|
|
||||||
HISTORY_FILE = CONFIG_DIR / "vibehistory"
|
|
||||||
PROJECT_DOC_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"]
|
|
||||||
|
|
||||||
|
|
||||||
class MissingAPIKeyError(RuntimeError):
|
class MissingAPIKeyError(RuntimeError):
|
||||||
def __init__(self, env_key: str, provider_name: str) -> None:
|
def __init__(self, env_key: str, provider_name: str) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
|
|
@ -94,7 +77,7 @@ class TomlFileSettingsSource(PydanticBaseSettingsSource):
|
||||||
self.toml_data = self._load_toml()
|
self.toml_data = self._load_toml()
|
||||||
|
|
||||||
def _load_toml(self) -> dict[str, Any]:
|
def _load_toml(self) -> dict[str, Any]:
|
||||||
file = CONFIG_FILE
|
file = CONFIG_FILE.path
|
||||||
try:
|
try:
|
||||||
with file.open("rb") as f:
|
with file.open("rb") as f:
|
||||||
return tomllib.load(f)
|
return tomllib.load(f)
|
||||||
|
|
@ -134,7 +117,7 @@ class SessionLoggingConfig(BaseSettings):
|
||||||
@classmethod
|
@classmethod
|
||||||
def set_default_save_dir(cls, v: str) -> str:
|
def set_default_save_dir(cls, v: str) -> str:
|
||||||
if not v:
|
if not v:
|
||||||
return str(get_vibe_home() / "logs" / "session")
|
return str(SESSION_LOG_DIR.path)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("save_dir", mode="after")
|
@field_validator("save_dir", mode="after")
|
||||||
|
|
@ -350,7 +333,7 @@ class VibeConfig(BaseSettings):
|
||||||
)
|
)
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_prefix="VIBE_", case_sensitive=False, extra="forbid"
|
env_prefix="VIBE_", case_sensitive=False, extra="ignore"
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -364,9 +347,9 @@ class VibeConfig(BaseSettings):
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
custom_sp_path = (PROMPT_DIR / self.system_prompt_id).with_suffix(".md")
|
custom_sp_path = (PROMPT_DIR.path / self.system_prompt_id).with_suffix(".md")
|
||||||
if not custom_sp_path.is_file():
|
if not custom_sp_path.is_file():
|
||||||
raise MissingPromptFileError(self.system_prompt_id, str(PROMPT_DIR))
|
raise MissingPromptFileError(self.system_prompt_id, str(PROMPT_DIR.path))
|
||||||
return custom_sp_path.read_text()
|
return custom_sp_path.read_text()
|
||||||
|
|
||||||
def get_active_model(self) -> ModelConfig:
|
def get_active_model(self) -> ModelConfig:
|
||||||
|
|
@ -492,7 +475,7 @@ class VibeConfig(BaseSettings):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def save_updates(cls, updates: dict[str, Any]) -> None:
|
def save_updates(cls, updates: dict[str, Any]) -> None:
|
||||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
CONFIG_DIR.path.mkdir(parents=True, exist_ok=True)
|
||||||
current_config = TomlFileSettingsSource(cls).toml_data
|
current_config = TomlFileSettingsSource(cls).toml_data
|
||||||
|
|
||||||
def deep_merge(target: dict, source: dict) -> None:
|
def deep_merge(target: dict, source: dict) -> None:
|
||||||
|
|
@ -522,7 +505,7 @@ class VibeConfig(BaseSettings):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def dump_config(cls, config: dict[str, Any]) -> None:
|
def dump_config(cls, config: dict[str, Any]) -> None:
|
||||||
with CONFIG_FILE.open("wb") as f:
|
with CONFIG_FILE.path.open("wb") as f:
|
||||||
tomli_w.dump(config, f)
|
tomli_w.dump(config, f)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -530,21 +513,21 @@ class VibeConfig(BaseSettings):
|
||||||
if agent is None:
|
if agent is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
agent_config_path = (AGENT_DIR / agent).with_suffix(".toml")
|
agent_config_path = (AGENT_DIR.path / agent).with_suffix(".toml")
|
||||||
try:
|
try:
|
||||||
return tomllib.load(agent_config_path.open("rb"))
|
return tomllib.load(agent_config_path.open("rb"))
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Config '{agent}.toml' for agent not found in {AGENT_DIR}"
|
f"Config '{agent}.toml' for agent not found in {AGENT_DIR.path}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _migrate(cls) -> None:
|
def _migrate(cls) -> None:
|
||||||
if not CONFIG_FILE.exists():
|
if not CONFIG_FILE.path.is_file():
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with CONFIG_FILE.open("rb") as f:
|
with CONFIG_FILE.path.open("rb") as f:
|
||||||
config = tomllib.load(f)
|
config = tomllib.load(f)
|
||||||
except (OSError, tomllib.TOMLDecodeError):
|
except (OSError, tomllib.TOMLDecodeError):
|
||||||
return
|
return
|
||||||
|
|
|
||||||
51
vibe/core/config_path.py
Normal file
51
vibe/core/config_path.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigPath:
|
||||||
|
def __init__(self, path_resolver: Callable[[], Path]) -> None:
|
||||||
|
self._path_resolver = path_resolver
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self) -> Path:
|
||||||
|
return self._path_resolver()
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_VIBE_HOME = Path.home() / ".vibe"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_vibe_home() -> Path:
|
||||||
|
if vibe_home := os.getenv("VIBE_HOME"):
|
||||||
|
return Path(vibe_home).expanduser().resolve()
|
||||||
|
return _DEFAULT_VIBE_HOME
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_config_file() -> Path:
|
||||||
|
if (candidate := Path.cwd() / ".vibe" / "config.toml").is_file():
|
||||||
|
return candidate
|
||||||
|
return _get_vibe_home() / "config.toml"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_local_tools_dir(dir: Path) -> Path | None:
|
||||||
|
if (candidate := dir / ".vibe" / "tools").is_dir():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
VIBE_HOME = ConfigPath(_get_vibe_home)
|
||||||
|
GLOBAL_CONFIG_FILE = ConfigPath(lambda: VIBE_HOME.path / "config.toml")
|
||||||
|
GLOBAL_ENV_FILE = ConfigPath(lambda: VIBE_HOME.path / ".env")
|
||||||
|
GLOBAL_TOOLS_DIR = ConfigPath(lambda: VIBE_HOME.path / "tools")
|
||||||
|
SESSION_LOG_DIR = ConfigPath(lambda: VIBE_HOME.path / "logs" / "session")
|
||||||
|
|
||||||
|
CONFIG_FILE = ConfigPath(_resolve_config_file)
|
||||||
|
CONFIG_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent)
|
||||||
|
LOG_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent / "logs")
|
||||||
|
AGENT_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent / "agents")
|
||||||
|
PROMPT_DIR = ConfigPath(lambda: CONFIG_FILE.path.parent / "prompts")
|
||||||
|
INSTRUCTIONS_FILE = ConfigPath(lambda: CONFIG_FILE.path.parent / "instructions.md")
|
||||||
|
HISTORY_FILE = ConfigPath(lambda: CONFIG_FILE.path.parent / "vibehistory")
|
||||||
|
LOG_FILE = ConfigPath(lambda: CONFIG_FILE.path.parent / "vibe.log")
|
||||||
|
|
@ -124,8 +124,10 @@ class OpenAIAdapter(APIAdapter):
|
||||||
|
|
||||||
if enable_streaming:
|
if enable_streaming:
|
||||||
payload["stream"] = True
|
payload["stream"] = True
|
||||||
|
stream_options = {"include_usage": True}
|
||||||
if provider.name == "mistral":
|
if provider.name == "mistral":
|
||||||
payload["stream_options"] = {"stream_tool_calls": True}
|
stream_options["stream_tool_calls"] = True
|
||||||
|
payload["stream_options"] = stream_options
|
||||||
|
|
||||||
headers = self.build_headers(api_key)
|
headers = self.build_headers(api_key)
|
||||||
|
|
||||||
|
|
@ -141,11 +143,11 @@ class OpenAIAdapter(APIAdapter):
|
||||||
message = LLMMessage.model_validate(data["choices"][0]["delta"])
|
message = LLMMessage.model_validate(data["choices"][0]["delta"])
|
||||||
else:
|
else:
|
||||||
raise ValueError("Invalid response data")
|
raise ValueError("Invalid response data")
|
||||||
finish_reason = data["choices"][0]["finish_reason"]
|
finish_reason = data["choices"][0].get("finish_reason", None)
|
||||||
|
|
||||||
elif "message" in data:
|
elif "message" in data:
|
||||||
message = LLMMessage.model_validate(data["message"])
|
message = LLMMessage.model_validate(data["message"])
|
||||||
finish_reason = data["finish_reason"]
|
finish_reason = data["choices"][0].get("finish_reason", None)
|
||||||
elif "delta" in data:
|
elif "delta" in data:
|
||||||
message = LLMMessage.model_validate(data["delta"])
|
message = LLMMessage.model_validate(data["delta"])
|
||||||
finish_reason = None
|
finish_reason = None
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ import sys
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from vibe.core.config import INSTRUCTIONS_FILE, PROJECT_DOC_FILENAMES
|
from vibe.core.config import PROJECT_DOC_FILENAMES
|
||||||
|
from vibe.core.config_path import INSTRUCTIONS_FILE
|
||||||
from vibe.core.llm.format import get_active_tool_classes
|
from vibe.core.llm.format import get_active_tool_classes
|
||||||
from vibe.core.prompts import UtilityPrompt
|
from vibe.core.prompts import UtilityPrompt
|
||||||
from vibe.core.utils import is_dangerous_directory, is_windows
|
from vibe.core.utils import is_dangerous_directory, is_windows
|
||||||
|
|
@ -21,7 +22,7 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
def _load_user_instructions() -> str:
|
def _load_user_instructions() -> str:
|
||||||
try:
|
try:
|
||||||
return INSTRUCTIONS_FILE.read_text("utf-8", errors="ignore")
|
return INSTRUCTIONS_FILE.path.read_text("utf-8", errors="ignore")
|
||||||
except (FileNotFoundError, OSError):
|
except (FileNotFoundError, OSError):
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import sys
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from vibe import VIBE_ROOT
|
from vibe import VIBE_ROOT
|
||||||
from vibe.core.config import get_vibe_home
|
from vibe.core.config_path import GLOBAL_TOOLS_DIR, resolve_local_tools_dir
|
||||||
from vibe.core.tools.base import BaseTool, BaseToolConfig
|
from vibe.core.tools.base import BaseTool, BaseToolConfig
|
||||||
from vibe.core.tools.mcp import (
|
from vibe.core.tools.mcp import (
|
||||||
RemoteTool,
|
RemoteTool,
|
||||||
|
|
@ -60,16 +60,11 @@ class ToolManager:
|
||||||
if path.is_dir():
|
if path.is_dir():
|
||||||
paths.append(path)
|
paths.append(path)
|
||||||
|
|
||||||
cwd = config.effective_workdir
|
if (tools_dir := resolve_local_tools_dir(config.effective_workdir)) is not None:
|
||||||
for directory in (cwd, *cwd.parents):
|
paths.append(tools_dir)
|
||||||
tools_dir = directory / ".vibe" / "tools"
|
|
||||||
if tools_dir.is_dir():
|
|
||||||
paths.append(tools_dir)
|
|
||||||
break
|
|
||||||
|
|
||||||
global_tools = get_vibe_home() / "tools"
|
if GLOBAL_TOOLS_DIR.path.is_dir():
|
||||||
if global_tools.is_dir():
|
paths.append(GLOBAL_TOOLS_DIR.path)
|
||||||
paths.append(global_tools)
|
|
||||||
|
|
||||||
unique: list[Path] = []
|
unique: list[Path] = []
|
||||||
seen: set[Path] = set()
|
seen: set[Path] = set()
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,11 @@ class Role(StrEnum):
|
||||||
tool = auto()
|
tool = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalResponse(StrEnum):
|
||||||
|
YES = "y"
|
||||||
|
NO = "n"
|
||||||
|
|
||||||
|
|
||||||
class LLMMessage(BaseModel):
|
class LLMMessage(BaseModel):
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
@ -219,11 +224,6 @@ class BaseEvent(BaseModel, ABC):
|
||||||
|
|
||||||
class AssistantEvent(BaseEvent):
|
class AssistantEvent(BaseEvent):
|
||||||
content: str
|
content: str
|
||||||
prompt_tokens: int = 0
|
|
||||||
completion_tokens: int = 0
|
|
||||||
session_total_tokens: int = 0
|
|
||||||
last_turn_duration: float = 0.0
|
|
||||||
tokens_per_second: float = 0.0
|
|
||||||
stopped_by_middleware: bool = False
|
stopped_by_middleware: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -263,9 +263,11 @@ class OutputFormat(StrEnum):
|
||||||
|
|
||||||
|
|
||||||
type AsyncApprovalCallback = Callable[
|
type AsyncApprovalCallback = Callable[
|
||||||
[str, dict[str, Any], str], Awaitable[tuple[str, str | None]]
|
[str, dict[str, Any], str], Awaitable[tuple[ApprovalResponse, str | None]]
|
||||||
]
|
]
|
||||||
|
|
||||||
type SyncApprovalCallback = Callable[[str, dict[str, Any], str], tuple[str, str | None]]
|
type SyncApprovalCallback = Callable[
|
||||||
|
[str, dict[str, Any], str], tuple[ApprovalResponse, str | None]
|
||||||
|
]
|
||||||
|
|
||||||
type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback
|
type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine
|
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
from enum import Enum, StrEnum, auto
|
from enum import Enum, auto
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -14,16 +14,10 @@ from typing import Any
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from vibe.core import __version__
|
from vibe.core import __version__
|
||||||
from vibe.core.config import CONFIG_DIR, CONFIG_FILE, GLOBAL_CONFIG_FILE, Backend
|
from vibe.core.config import Backend
|
||||||
|
from vibe.core.config_path import CONFIG_FILE, GLOBAL_CONFIG_FILE, LOG_DIR, LOG_FILE
|
||||||
from vibe.core.types import BaseEvent, ToolResultEvent
|
from vibe.core.types import BaseEvent, ToolResultEvent
|
||||||
|
|
||||||
|
|
||||||
class ApprovalResponse(StrEnum):
|
|
||||||
YES = "y"
|
|
||||||
NO = "n"
|
|
||||||
ALWAYS = "a"
|
|
||||||
|
|
||||||
|
|
||||||
CANCELLATION_TAG = "user_cancellation"
|
CANCELLATION_TAG = "user_cancellation"
|
||||||
TOOL_ERROR_TAG = "tool_error"
|
TOOL_ERROR_TAG = "tool_error"
|
||||||
VIBE_STOP_EVENT_TAG = "vibe_stop_event"
|
VIBE_STOP_EVENT_TAG = "vibe_stop_event"
|
||||||
|
|
@ -141,22 +135,21 @@ def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]:
|
||||||
return False, ""
|
return False, ""
|
||||||
|
|
||||||
|
|
||||||
LOG_DIR = CONFIG_DIR
|
LOG_DIR.path.mkdir(parents=True, exist_ok=True)
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
LOG_FILE = LOG_DIR / "vibe.log"
|
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s %(levelname)s %(message)s",
|
format="%(asctime)s %(levelname)s %(message)s",
|
||||||
handlers=[logging.FileHandler(LOG_FILE, "a", "utf-8")],
|
handlers=[logging.FileHandler(LOG_FILE.path, "a", "utf-8")],
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("vibe")
|
logger = logging.getLogger("vibe")
|
||||||
logger.info("Using config: %s", CONFIG_FILE)
|
logger.info("Using config: %s", CONFIG_FILE.path)
|
||||||
if CONFIG_FILE != GLOBAL_CONFIG_FILE and GLOBAL_CONFIG_FILE.is_file():
|
if CONFIG_FILE.path != GLOBAL_CONFIG_FILE.path and GLOBAL_CONFIG_FILE.path.is_file():
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Project config active (%s); ignoring global config (%s)",
|
"Project config active (%s); ignoring global config (%s)",
|
||||||
CONFIG_FILE,
|
CONFIG_FILE.path,
|
||||||
GLOBAL_CONFIG_FILE,
|
GLOBAL_CONFIG_FILE.path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import sys
|
||||||
from rich import print as rprint
|
from rich import print as rprint
|
||||||
from textual.app import App
|
from textual.app import App
|
||||||
|
|
||||||
from vibe.core.config import GLOBAL_ENV_FILE
|
from vibe.core.config_path import GLOBAL_ENV_FILE
|
||||||
from vibe.setup.onboarding.screens import (
|
from vibe.setup.onboarding.screens import (
|
||||||
ApiKeyScreen,
|
ApiKeyScreen,
|
||||||
ThemeSelectionScreen,
|
ThemeSelectionScreen,
|
||||||
|
|
@ -34,7 +34,7 @@ def run_onboarding(app: App | None = None) -> None:
|
||||||
rprint(
|
rprint(
|
||||||
f"\n[yellow]Warning: Could not save API key to .env file: {err}[/]"
|
f"\n[yellow]Warning: Could not save API key to .env file: {err}[/]"
|
||||||
"\n[dim]The API key is set for this session only. "
|
"\n[dim]The API key is set for this session only. "
|
||||||
f"You may need to set it manually in {GLOBAL_ENV_FILE}[/]\n"
|
f"You may need to set it manually in {GLOBAL_ENV_FILE.path}[/]\n"
|
||||||
)
|
)
|
||||||
case "completed":
|
case "completed":
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ from textual.validation import Length
|
||||||
from textual.widgets import Input, Link, Static
|
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.core.config import GLOBAL_ENV_FILE, VibeConfig
|
from vibe.core.config import VibeConfig
|
||||||
|
from vibe.core.config_path import GLOBAL_ENV_FILE
|
||||||
from vibe.setup.onboarding.base import OnboardingScreen
|
from vibe.setup.onboarding.base import OnboardingScreen
|
||||||
|
|
||||||
PROVIDER_HELP = {
|
PROVIDER_HELP = {
|
||||||
|
|
@ -24,8 +25,8 @@ CONFIG_DOCS_URL = (
|
||||||
|
|
||||||
|
|
||||||
def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
|
def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
|
||||||
GLOBAL_ENV_FILE.parent.mkdir(parents=True, exist_ok=True)
|
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
set_key(GLOBAL_ENV_FILE, env_key, api_key)
|
set_key(GLOBAL_ENV_FILE.path, env_key, api_key)
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyScreen(OnboardingScreen):
|
class ApiKeyScreen(OnboardingScreen):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue