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]
|
||||
|
||||
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:
|
||||
needs: configure
|
||||
name: ${{ matrix.os }}-${{ matrix.arch }}
|
||||
strategy:
|
||||
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
|
||||
matrix: ${{ fromJSON(needs.configure.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
steps:
|
||||
- name: echo
|
||||
run: echo github.repository=${{ github.repository }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
|
|
@ -55,10 +66,10 @@ jobs:
|
|||
python-version: "3.12"
|
||||
|
||||
- name: Sync dependencies
|
||||
run: uv sync --all-extras
|
||||
run: uv sync --no-dev --group build
|
||||
|
||||
- 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)
|
||||
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": [
|
||||
{
|
||||
"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/),
|
||||
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
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
|
|
@ -52,13 +52,9 @@ runs:
|
|||
working-directory: ${{ github.action_path }}
|
||||
env:
|
||||
MISTRAL_API_KEY: "${{ inputs.MISTRAL_API_KEY }}"
|
||||
PROMPT_INPUT: "${{ inputs.prompt }}"
|
||||
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 }}"
|
||||
uv run --directory "${{ github.action_path }}" vibe \
|
||||
--auto-approve \
|
||||
-p "${ESCAPED_PROMPT}"
|
||||
-p "$PROMPT_INPUT"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
id = "mistral-vibe"
|
||||
name = "Mistral Vibe"
|
||||
description = "Mistral's open-source coding assistant"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
schema_version = 1
|
||||
authors = ["Mistral AI"]
|
||||
repository = "https://github.com/mistralai/mistral-vibe"
|
||||
|
|
@ -11,25 +11,25 @@ name = "Mistral Vibe"
|
|||
icon = "./icons/mistral_vibe.svg"
|
||||
|
||||
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/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"
|
||||
|
||||
[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"
|
||||
|
||||
# [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"
|
||||
# cmd = "./vibe-acp"
|
||||
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.1/vibe-acp-linux-aarch64-1.1.3.zip"
|
||||
cmd = "./vibe-acp"
|
||||
|
||||
[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"
|
||||
|
||||
# [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"
|
||||
# cmd = "./vibe-acp.exe"
|
||||
[agent_servers.mistral-vibe.targets.windows-aarch64]
|
||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.1/vibe-acp-windows-aarch64-1.1.3.zip"
|
||||
cmd = "./vibe-acp.exe"
|
||||
|
||||
[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"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "mistral-vibe"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
description = "Minimal CLI coding agent by Mistral"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
|
@ -86,10 +86,11 @@ dev = [
|
|||
"twine>=5.0.0",
|
||||
"typos>=1.34.0",
|
||||
"vulture>=2.14",
|
||||
"pyinstaller>=6.17.0",
|
||||
"pytest-xdist>=3.8.0",
|
||||
]
|
||||
|
||||
build = ["pyinstaller>=6.17.0"]
|
||||
|
||||
[tool.pyright]
|
||||
pythonVersion = "3.12"
|
||||
reportMissingTypeStubs = false
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import asyncio
|
|||
from collections.abc import AsyncGenerator
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from acp import (
|
||||
|
|
@ -27,8 +28,10 @@ from acp.schema import (
|
|||
)
|
||||
from pydantic import BaseModel
|
||||
import pytest
|
||||
import tomli_w
|
||||
|
||||
from tests import TESTS_ROOT
|
||||
from tests.conftest import get_base_config
|
||||
from tests.mock.utils import get_mocking_env, mock_llm_chunk
|
||||
from vibe.acp.utils import ToolOption
|
||||
from vibe.core.types import FunctionCall, ToolCall
|
||||
|
|
@ -38,6 +41,63 @@ MOCK_ENTRYPOINT_PATH = "tests/mock/mock_entrypoint.py"
|
|||
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):
|
||||
jsonrpc: str = "2.0"
|
||||
id: int | str
|
||||
|
|
@ -127,10 +187,15 @@ class WriteTextFileJsonRpcResponse(JsonRpcResponse):
|
|||
|
||||
|
||||
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]:
|
||||
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(
|
||||
*cmd,
|
||||
|
|
@ -138,11 +203,7 @@ async def get_acp_agent_process(
|
|||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=TESTS_ROOT.parent,
|
||||
env={
|
||||
**current_env,
|
||||
**(mock_env or {}),
|
||||
**({"MISTRAL_API_KEY": "mock"} if mock else {}),
|
||||
},
|
||||
env=env,
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -322,9 +383,11 @@ async def initialize_session(acp_agent_process: asyncio.subprocess.Process) -> s
|
|||
|
||||
class TestSessionManagement:
|
||||
@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)])
|
||||
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(
|
||||
process,
|
||||
InitializeJsonRpcRequest(
|
||||
|
|
@ -359,9 +422,11 @@ class TestSessionManagement:
|
|||
|
||||
class TestSessionUpdates:
|
||||
@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)])
|
||||
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
|
||||
if process.returncode is not None and process.stderr:
|
||||
stderr_data = await process.stderr.read()
|
||||
|
|
@ -395,7 +460,7 @@ class TestSessionUpdates:
|
|||
assert response.params.update.content.text == "Hi"
|
||||
|
||||
@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_llm_chunk(content="Hey"),
|
||||
mock_llm_chunk(
|
||||
|
|
@ -416,7 +481,9 @@ class TestSessionUpdates:
|
|||
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)
|
||||
|
||||
await send_json_rpc(
|
||||
|
|
@ -491,32 +558,32 @@ async def start_session_with_request_permission(
|
|||
return last_response
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="Disabled until we have a way to properly mock the fs and acp interactions"
|
||||
)
|
||||
class TestToolCallStructure:
|
||||
@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 = [
|
||||
mock_llm_chunk(content="Hey"),
|
||||
mock_llm_chunk(
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="write_file",
|
||||
arguments='{"path":"test.txt","content":"hello, world!"'
|
||||
',"overwrite":true}',
|
||||
name="grep",
|
||||
arguments='{"pattern":"auth","path":".","max_matches":null,"use_default_ignore":true}',
|
||||
),
|
||||
type="function",
|
||||
index=0,
|
||||
)
|
||||
],
|
||||
name="write_file",
|
||||
finish_reason="stop",
|
||||
name="grep",
|
||||
finish_reason="tool_calls",
|
||||
),
|
||||
]
|
||||
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)
|
||||
await send_json_rpc(
|
||||
process,
|
||||
|
|
@ -527,14 +594,15 @@ class TestToolCallStructure:
|
|||
prompt=[
|
||||
TextContentBlock(
|
||||
type="text",
|
||||
text="Create a new file named test.txt "
|
||||
"with content 'hello, world!'",
|
||||
text="Search for files containing the pattern 'auth'",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
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)
|
||||
|
||||
# Look for tool call request permission updates
|
||||
|
|
@ -543,7 +611,8 @@ class TestToolCallStructure:
|
|||
]
|
||||
|
||||
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]
|
||||
|
|
@ -552,32 +621,35 @@ class TestToolCallStructure:
|
|||
assert first_request.params.toolCall.toolCallId is not None
|
||||
|
||||
@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 = [
|
||||
mock_llm_chunk(content="Hey"),
|
||||
mock_llm_chunk(
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="write_file",
|
||||
arguments='{"path":"test.txt","content":"hello, world!"'
|
||||
',"overwrite":true}',
|
||||
name="grep",
|
||||
arguments='{"pattern":"auth","path":".","max_matches":null,"use_default_ignore":true}',
|
||||
),
|
||||
type="function",
|
||||
index=0,
|
||||
)
|
||||
],
|
||||
name="write_file",
|
||||
name="grep",
|
||||
finish_reason="tool_calls",
|
||||
),
|
||||
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)
|
||||
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(
|
||||
process, "Create a file named test.txt"
|
||||
process, "Search for files containing the pattern 'auth'"
|
||||
)
|
||||
assert permission_request.params is not None
|
||||
selected_option_id = ToolOption.ALLOW_ONCE
|
||||
|
|
@ -613,34 +685,37 @@ class TestToolCallStructure:
|
|||
assert approved_tool_call is not None
|
||||
|
||||
@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 = [
|
||||
mock_llm_chunk(content="Hey"),
|
||||
mock_llm_chunk(
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="write_file",
|
||||
arguments='{"path":"test.txt","content":"hello, world!"'
|
||||
',"overwrite":false}',
|
||||
name="grep",
|
||||
arguments='{"pattern":"auth","path":".","max_matches":null,"use_default_ignore":true}',
|
||||
),
|
||||
type="function",
|
||||
index=0,
|
||||
)
|
||||
],
|
||||
name="write_file",
|
||||
name="grep",
|
||||
finish_reason="tool_calls",
|
||||
),
|
||||
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",
|
||||
finish_reason="stop",
|
||||
),
|
||||
]
|
||||
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(
|
||||
process, "Create a file named test.txt"
|
||||
process, "Search for files containing the pattern 'auth'"
|
||||
)
|
||||
assert permission_request.params is not None
|
||||
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.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 = [
|
||||
mock_llm_chunk(content="Hey"),
|
||||
mock_llm_chunk(
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="bash",
|
||||
arguments='{"command":"sleep 3","timeout":null}',
|
||||
name="grep",
|
||||
arguments='{"pattern":"auth","path":".","max_matches":null,"use_default_ignore":true}',
|
||||
),
|
||||
type="function",
|
||||
index=0,
|
||||
)
|
||||
],
|
||||
name="bash",
|
||||
name="grep",
|
||||
finish_reason="tool_calls",
|
||||
),
|
||||
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)
|
||||
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)
|
||||
await send_json_rpc(
|
||||
process,
|
||||
|
|
@ -708,7 +788,8 @@ class TestToolCallStructure:
|
|||
sessionId=session_id,
|
||||
prompt=[
|
||||
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
|
||||
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 = [
|
||||
mock_llm_chunk(content="Hey"),
|
||||
mock_llm_chunk(
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
function=FunctionCall(
|
||||
name="write_file",
|
||||
arguments='{"path":"/test.txt","content":"hello, world!"'
|
||||
',"overwrite":true}',
|
||||
name="grep",
|
||||
arguments='{"pattern":"auth","path":"/nonexistent","max_matches":null,"use_default_ignore":true}',
|
||||
),
|
||||
type="function",
|
||||
index=0,
|
||||
)
|
||||
],
|
||||
name="write_file",
|
||||
name="grep",
|
||||
finish_reason="tool_calls",
|
||||
),
|
||||
mock_llm_chunk(
|
||||
content="The file /test.txt has not been created "
|
||||
"because it's outside the project directory",
|
||||
content="The search for 'auth' has failed "
|
||||
"because the path does not exist",
|
||||
finish_reason="stop",
|
||||
),
|
||||
]
|
||||
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(
|
||||
process, "Create a file named /test.txt"
|
||||
process,
|
||||
"Search for files containing the pattern 'auth' in /nonexistent",
|
||||
)
|
||||
assert permission_request.params is not None
|
||||
selected_option_id = ToolOption.ALLOW_ONCE
|
||||
|
|
@ -802,7 +887,9 @@ class TestCancellationStructure:
|
|||
"(and not only at tool call time)"
|
||||
)
|
||||
@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 = [
|
||||
mock_llm_chunk(content="Hey"),
|
||||
mock_llm_chunk(
|
||||
|
|
@ -827,7 +914,9 @@ class TestCancellationStructure:
|
|||
),
|
||||
]
|
||||
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(
|
||||
process, "Create a file named test.txt"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class TestACPInitialize:
|
|||
),
|
||||
)
|
||||
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 == []
|
||||
|
|
@ -63,7 +63,7 @@ class TestACPInitialize:
|
|||
),
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ def acp_agent(backend: FakeBackend) -> VibeAcpAgent:
|
|||
],
|
||||
)
|
||||
|
||||
VibeConfig.dump_config(config.model_dump())
|
||||
|
||||
class PatchedAgent(Agent):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
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
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
|
@ -249,6 +251,58 @@ class TestBackend:
|
|||
assert e.value.reason == response.reason_phrase
|
||||
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.parametrize("backend_type", [Backend.MISTRAL, Backend.GENERIC])
|
||||
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 pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from pydantic.fields import FieldInfo
|
||||
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
|
||||
import pytest
|
||||
import tomli_w
|
||||
|
||||
_in_mem_config: dict[str, Any] = {}
|
||||
from vibe.core import config_path
|
||||
|
||||
|
||||
class InMemSettingsSource(PydanticBaseSettingsSource):
|
||||
def __init__(self, settings_cls: type[BaseSettings]) -> None:
|
||||
super().__init__(settings_cls)
|
||||
|
||||
def get_field_value(
|
||||
self, field: FieldInfo, field_name: str
|
||||
) -> tuple[Any, str, bool]:
|
||||
return _in_mem_config.get(field_name), field_name, False
|
||||
|
||||
def __call__(self) -> dict[str, Any]:
|
||||
return _in_mem_config
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def _patch_vibe_config() -> None:
|
||||
"""Patch VibeConfig.settings_customise_sources to only use init_settings in tests.
|
||||
|
||||
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]
|
||||
def get_base_config() -> dict[str, Any]:
|
||||
return {
|
||||
"active_model": "devstral-latest",
|
||||
"providers": [
|
||||
{
|
||||
"name": "mistral",
|
||||
"api_base": "https://api.mistral.ai/v1",
|
||||
"api_key_env_var": "MISTRAL_API_KEY",
|
||||
"backend": "mistral",
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"name": "mistral-vibe-cli-latest",
|
||||
"provider": "mistral",
|
||||
"alias": "devstral-latest",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_in_mem_config() -> None:
|
||||
"""Reset in-memory config before each test to prevent test isolation issues.
|
||||
def config_dir(
|
||||
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,
|
||||
preventing race conditions and test interference when tests run in parallel
|
||||
or when VibeConfig.save_updates() modifies the shared _in_mem_config dict.
|
||||
"""
|
||||
global _in_mem_config
|
||||
_in_mem_config = {}
|
||||
monkeypatch.setattr(config_path, "_DEFAULT_VIBE_HOME", config_dir)
|
||||
return config_dir
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
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:
|
||||
raise SystemExit(code)
|
||||
|
||||
|
|
@ -33,7 +27,6 @@ def _exit_raiser(code: int = 0) -> None:
|
|||
def test_exits_on_cancel(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
) -> None:
|
||||
_patch_env_file(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(sys, "exit", _exit_raiser)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
|
|
@ -47,7 +40,6 @@ def test_exits_on_cancel(
|
|||
def test_warns_on_save_error(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
) -> None:
|
||||
_patch_env_file(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(sys, "exit", _exit_raiser)
|
||||
|
||||
onboarding.run_onboarding(StubApp("save_error:disk full"))
|
||||
|
|
@ -60,7 +52,6 @@ def test_warns_on_save_error(
|
|||
def test_successfully_completes(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
) -> None:
|
||||
_patch_env_file(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(sys, "exit", _exit_raiser)
|
||||
|
||||
onboarding.run_onboarding(StubApp("completed"))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import tomllib
|
||||
|
||||
import pytest
|
||||
from textual.events import Resize
|
||||
|
|
@ -10,9 +10,8 @@ from textual.geometry import Size
|
|||
from textual.pilot import Pilot
|
||||
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
|
||||
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.theme_selection import THEMES, ThemeSelectionScreen
|
||||
|
||||
|
|
@ -31,32 +30,6 @@ async def _wait_for(
|
|||
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:
|
||||
welcome_screen = pilot.app.get_screen("welcome")
|
||||
await _wait_for(
|
||||
|
|
@ -67,10 +40,8 @@ async def pass_welcome_screen(pilot: Pilot) -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_gets_through_the_onboarding_successfully(
|
||||
onboarding_app: tuple[OnboardingApp, Path, dict[str, Any]],
|
||||
) -> None:
|
||||
app, env_file, config_updates = onboarding_app
|
||||
async def test_ui_gets_through_the_onboarding_successfully() -> None:
|
||||
app = OnboardingApp()
|
||||
api_key_value = "sk-onboarding-test-key"
|
||||
|
||||
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 env_file.is_file()
|
||||
env_contents = env_file.read_text(encoding="utf-8")
|
||||
assert GLOBAL_ENV_FILE.path.is_file()
|
||||
env_contents = GLOBAL_ENV_FILE.path.read_text(encoding="utf-8")
|
||||
assert "MISTRAL_API_KEY" in env_contents
|
||||
assert api_key_value in env_contents
|
||||
|
||||
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
|
||||
async def test_ui_can_pick_a_theme_and_saves_selection(
|
||||
onboarding_app: tuple[OnboardingApp, Path, dict[str, Any]],
|
||||
) -> None:
|
||||
app, _, config_updates = onboarding_app
|
||||
async def test_ui_can_pick_a_theme_and_saves_selection(config_dir: Path) -> None:
|
||||
app = OnboardingApp()
|
||||
|
||||
async with app.run_test() as 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 _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.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):
|
||||
|
|
@ -14,9 +20,11 @@ class SnapshotTestAppWithUpdate(BaseSnapshotTestApp):
|
|||
version_update_notifier = FakeVersionUpdateGateway(
|
||||
update=VersionUpdate(latest_version="1000.2.0")
|
||||
)
|
||||
update_cache_repository = FakeUpdateCacheRepository()
|
||||
super().__init__(
|
||||
config=config,
|
||||
version_update_notifier=version_update_notifier,
|
||||
update_cache_repository=update_cache_repository,
|
||||
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.builtins.todo import TodoArgs
|
||||
from vibe.core.types import (
|
||||
ApprovalResponse,
|
||||
AssistantEvent,
|
||||
FunctionCall,
|
||||
LLMChunk,
|
||||
|
|
@ -30,11 +31,7 @@ from vibe.core.types import (
|
|||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
)
|
||||
from vibe.core.utils import (
|
||||
ApprovalResponse,
|
||||
CancellationReason,
|
||||
get_user_cancellation_message,
|
||||
)
|
||||
from vibe.core.utils import CancellationReason, get_user_cancellation_message
|
||||
|
||||
|
||||
class InjectBeforeMiddleware:
|
||||
|
|
@ -147,23 +144,6 @@ async def test_act_emits_user_and_assistant_msgs(observer_capture) -> None:
|
|||
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
|
||||
async def test_act_streams_batched_chunks_in_order() -> None:
|
||||
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.builtins.todo import TodoItem
|
||||
from vibe.core.types import (
|
||||
ApprovalResponse,
|
||||
AssistantEvent,
|
||||
BaseEvent,
|
||||
FunctionCall,
|
||||
|
|
@ -24,7 +25,6 @@ from vibe.core.types import (
|
|||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
)
|
||||
from vibe.core.utils import ApprovalResponse
|
||||
|
||||
|
||||
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:
|
||||
def approval_callback(
|
||||
_tool_name: str, _args: dict[str, Any], _tool_call_id: str
|
||||
) -> tuple[str, str | None]:
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
return (ApprovalResponse.YES, None)
|
||||
|
||||
agent = make_agent(
|
||||
|
|
@ -175,7 +175,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal
|
|||
|
||||
def approval_callback(
|
||||
_tool_name: str, _args: dict[str, Any], _tool_call_id: str
|
||||
) -> tuple[str, str | None]:
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
return (ApprovalResponse.NO, custom_feedback)
|
||||
|
||||
agent = make_agent(
|
||||
|
|
@ -237,14 +237,20 @@ async def test_tool_call_skipped_when_permission_is_never() -> None:
|
|||
|
||||
|
||||
@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 = []
|
||||
agent_ref: Agent | None = None
|
||||
|
||||
def approval_callback(
|
||||
tool_name: str, _args: dict[str, Any], _tool_call_id: str
|
||||
) -> tuple[str, str | None]:
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
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(
|
||||
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"),
|
||||
]),
|
||||
)
|
||||
agent_ref = agent
|
||||
|
||||
events1 = await act_and_collect_events(agent, "First 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 callback_invocations[0] == "todo"
|
||||
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 vibe.cli.update_notifier.version_update_gateway import (
|
||||
from vibe.cli.update_notifier.ports.version_update_gateway import (
|
||||
VersionUpdate,
|
||||
VersionUpdateGateway,
|
||||
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 pytest
|
||||
|
||||
from vibe.cli.update_notifier.github_version_update_gateway import (
|
||||
from vibe.cli.update_notifier import (
|
||||
GitHubVersionUpdateGateway,
|
||||
)
|
||||
from vibe.cli.update_notifier.version_update_gateway import (
|
||||
VersionUpdateGatewayCause,
|
||||
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
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
import pytest
|
||||
from textual.app import Notification
|
||||
|
||||
from vibe.cli.textual_ui.app import VibeApp
|
||||
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,
|
||||
)
|
||||
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,
|
||||
VersionUpdateGatewayCause,
|
||||
VersionUpdateGatewayError,
|
||||
|
|
@ -59,6 +64,7 @@ class VibeAppFactory(Protocol):
|
|||
self,
|
||||
*,
|
||||
notifier: FakeVersionUpdateGateway,
|
||||
update_cache_repository: FakeUpdateCacheRepository | None = None,
|
||||
config: VibeConfig | None = None,
|
||||
auto_approve: bool = False,
|
||||
current_version: str = "0.1.0",
|
||||
|
|
@ -67,9 +73,13 @@ class VibeAppFactory(Protocol):
|
|||
|
||||
@pytest.fixture
|
||||
def make_vibe_app(vibe_config_with_update_checks_enabled: VibeConfig) -> VibeAppFactory:
|
||||
update_cache_repository = FakeUpdateCacheRepository()
|
||||
|
||||
def _make_app(
|
||||
*,
|
||||
notifier: FakeVersionUpdateGateway,
|
||||
update_cache_repository: FakeUpdateCacheRepository
|
||||
| None = update_cache_repository,
|
||||
config: VibeConfig | None = None,
|
||||
auto_approve: bool = False,
|
||||
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,
|
||||
auto_approve=auto_approve,
|
||||
version_update_notifier=notifier,
|
||||
update_cache_repository=update_cache_repository,
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
)
|
||||
from vibe.cli.update_notifier.version_update import (
|
||||
VersionUpdateError,
|
||||
is_version_update_available,
|
||||
)
|
||||
from vibe.cli.update_notifier.version_update_gateway import (
|
||||
from vibe.cli.update_notifier import (
|
||||
UpdateCache,
|
||||
VersionUpdate,
|
||||
VersionUpdateGatewayCause,
|
||||
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
|
||||
|
|
@ -23,8 +32,10 @@ async def test_retrieves_the_latest_version_update_when_available() -> None:
|
|||
update=VersionUpdate(latest_version=latest_update)
|
||||
)
|
||||
|
||||
update = await is_version_update_available(
|
||||
version_update_notifier, current_version="1.0.0"
|
||||
update = await get_update_if_available(
|
||||
version_update_notifier,
|
||||
current_version="1.0.0",
|
||||
update_cache_repository=FakeUpdateCacheRepository(),
|
||||
)
|
||||
|
||||
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 = await is_version_update_available(
|
||||
version_update_notifier, current_version=current_version
|
||||
update = await get_update_if_available(
|
||||
version_update_notifier,
|
||||
current_version=current_version,
|
||||
update_cache_repository=FakeUpdateCacheRepository(),
|
||||
)
|
||||
|
||||
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 = await is_version_update_available(
|
||||
version_update_notifier, current_version=current_version
|
||||
update = await get_update_if_available(
|
||||
version_update_notifier,
|
||||
current_version=current_version,
|
||||
update_cache_repository=FakeUpdateCacheRepository(),
|
||||
)
|
||||
|
||||
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:
|
||||
version_update_notifier = FakeVersionUpdateGateway(update=None)
|
||||
|
||||
update = await is_version_update_available(
|
||||
version_update_notifier, current_version="1.0.0"
|
||||
update = await get_update_if_available(
|
||||
version_update_notifier,
|
||||
current_version="1.0.0",
|
||||
update_cache_repository=FakeUpdateCacheRepository(),
|
||||
)
|
||||
|
||||
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 = await is_version_update_available(
|
||||
version_update_notifier, current_version="1.0.0"
|
||||
update = await get_update_if_available(
|
||||
version_update_notifier,
|
||||
current_version="1.0.0",
|
||||
update_cache_repository=FakeUpdateCacheRepository(),
|
||||
)
|
||||
|
||||
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 = await is_version_update_available(
|
||||
version_update_notifier, current_version="1.0.0"
|
||||
update = await get_update_if_available(
|
||||
version_update_notifier,
|
||||
current_version="1.0.0",
|
||||
update_cache_repository=FakeUpdateCacheRepository(),
|
||||
)
|
||||
|
||||
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 = await is_version_update_available(
|
||||
version_update_notifier, current_version="invalid-version"
|
||||
update = await get_update_if_available(
|
||||
version_update_notifier,
|
||||
current_version="invalid-version",
|
||||
update_cache_repository=FakeUpdateCacheRepository(),
|
||||
)
|
||||
|
||||
assert update is None
|
||||
|
|
@ -139,8 +162,166 @@ async def test_raises_version_update_error(
|
|||
)
|
||||
|
||||
with pytest.raises(VersionUpdateError) as excinfo:
|
||||
await is_version_update_available(
|
||||
version_update_notifier, current_version="1.0.0"
|
||||
await get_update_if_available(
|
||||
version_update_notifier,
|
||||
current_version="1.0.0",
|
||||
update_cache_repository=FakeUpdateCacheRepository(),
|
||||
)
|
||||
|
||||
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]]
|
||||
name = "mistral-vibe"
|
||||
version = "1.1.2"
|
||||
version = "1.1.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "agent-client-protocol" },
|
||||
|
|
@ -683,9 +683,11 @@ dependencies = [
|
|||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
build = [
|
||||
{ name = "pyinstaller" },
|
||||
]
|
||||
dev = [
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pyinstaller" },
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
|
|
@ -720,9 +722,9 @@ requires-dist = [
|
|||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
build = [{ name = "pyinstaller", specifier = ">=6.17.0" }]
|
||||
dev = [
|
||||
{ name = "pre-commit", specifier = ">=4.2.0" },
|
||||
{ name = "pyinstaller", specifier = ">=6.17.0" },
|
||||
{ name = "pyright", specifier = ">=1.1.403" },
|
||||
{ name = "pytest", specifier = ">=8.3.5" },
|
||||
{ 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.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
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 (
|
||||
ApprovalResponse,
|
||||
AssistantEvent,
|
||||
AsyncApprovalCallback,
|
||||
ToolCallEvent,
|
||||
|
|
@ -211,10 +213,32 @@ class VibeAcpAgent(AcpAgent):
|
|||
return disabled
|
||||
|
||||
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(
|
||||
tool_name: str, args: dict[str, Any], tool_call_id: str
|
||||
) -> tuple[str, str | None]:
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
# Create the tool call update
|
||||
tool_call = ToolCall(toolCallId=tool_call_id)
|
||||
|
||||
|
|
@ -228,10 +252,10 @@ class VibeAcpAgent(AcpAgent):
|
|||
# Parse the response using isinstance for proper type narrowing
|
||||
if response.outcome.outcome == "selected":
|
||||
outcome = cast(AllowedOutcome, response.outcome)
|
||||
return self._handle_permission_selection(outcome.optionId)
|
||||
return _handle_permission_selection(outcome.optionId, tool_name)
|
||||
else:
|
||||
return (
|
||||
"n",
|
||||
ApprovalResponse.NO,
|
||||
str(
|
||||
get_user_cancellation_message(
|
||||
CancellationReason.OPERATION_CANCELLED
|
||||
|
|
@ -241,18 +265,6 @@ class VibeAcpAgent(AcpAgent):
|
|||
|
||||
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:
|
||||
if session_id not in self.sessions:
|
||||
raise RequestError.invalid_params({"session": "Not found"})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
|
||||
import pyperclip
|
||||
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:
|
||||
selected_texts = []
|
||||
|
|
@ -30,10 +53,21 @@ def copy_selection_to_clipboard(app: App) -> None:
|
|||
|
||||
combined_text = "\n".join(selected_texts)
|
||||
|
||||
for copy_fn in [_copy_osc52, pyperclip.copy, app.copy_to_clipboard]:
|
||||
try:
|
||||
pyperclip.copy(combined_text)
|
||||
app.notify("Selection added to clipboard", severity="information", timeout=2)
|
||||
except Exception:
|
||||
copy_fn(combined_text)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
app.notify(
|
||||
"Use Ctrl+c to copy selections in Vibe", severity="warning", timeout=3
|
||||
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
|
||||
severity="information",
|
||||
timeout=2,
|
||||
)
|
||||
break
|
||||
else:
|
||||
app.notify(
|
||||
"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.core.config import (
|
||||
CONFIG_FILE,
|
||||
HISTORY_FILE,
|
||||
INSTRUCTIONS_FILE,
|
||||
MissingAPIKeyError,
|
||||
MissingPromptFileError,
|
||||
VibeConfig,
|
||||
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.programmatic import run_programmatic
|
||||
from vibe.core.types import OutputFormat, ResumeSessionInfo
|
||||
|
|
@ -138,23 +136,23 @@ def main() -> None: # noqa: PLR0912, PLR0915
|
|||
run_onboarding()
|
||||
sys.exit(0)
|
||||
try:
|
||||
if not CONFIG_FILE.exists():
|
||||
if not CONFIG_FILE.path.exists():
|
||||
try:
|
||||
VibeConfig.save_updates(VibeConfig.create_default())
|
||||
except Exception as e:
|
||||
rprint(f"[yellow]Could not create default config file: {e}[/]")
|
||||
|
||||
if not INSTRUCTIONS_FILE.exists():
|
||||
if not INSTRUCTIONS_FILE.path.exists():
|
||||
try:
|
||||
INSTRUCTIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
INSTRUCTIONS_FILE.touch()
|
||||
INSTRUCTIONS_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
INSTRUCTIONS_FILE.path.touch()
|
||||
except Exception as e:
|
||||
rprint(f"[yellow]Could not create instructions file: {e}[/]")
|
||||
|
||||
if not HISTORY_FILE.exists():
|
||||
if not HISTORY_FILE.path.exists():
|
||||
try:
|
||||
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
HISTORY_FILE.write_text("Hello Vibe!\n", "utf-8")
|
||||
HISTORY_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
HISTORY_FILE.path.write_text("Hello Vibe!\n", "utf-8")
|
||||
except Exception as e:
|
||||
rprint(f"[yellow]Could not create history file: {e}[/]")
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
from enum import StrEnum, auto
|
||||
import os
|
||||
import subprocess
|
||||
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.welcome import WelcomeBanner
|
||||
from vibe.cli.update_notifier import (
|
||||
GitHubVersionUpdateGateway,
|
||||
VersionUpdate,
|
||||
FileSystemUpdateCacheRepository,
|
||||
PyPIVersionUpdateGateway,
|
||||
UpdateCacheRepository,
|
||||
VersionUpdateAvailability,
|
||||
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.agent import Agent
|
||||
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.types import LLMMessage, ResumeSessionInfo, Role
|
||||
from vibe.core.types import ApprovalResponse, LLMMessage, ResumeSessionInfo, Role
|
||||
from vibe.core.utils import (
|
||||
ApprovalResponse,
|
||||
CancellationReason,
|
||||
get_user_cancellation_message,
|
||||
is_dangerous_directory,
|
||||
|
|
@ -72,6 +73,10 @@ class VibeApp(App):
|
|||
Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False),
|
||||
Binding("ctrl+t", "toggle_todo", "Toggle Todo", show=False),
|
||||
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__(
|
||||
|
|
@ -83,6 +88,7 @@ class VibeApp(App):
|
|||
loaded_messages: list[LLMMessage] | None = None,
|
||||
session_info: ResumeSessionInfo | None = None,
|
||||
version_update_notifier: VersionUpdateGateway | None = None,
|
||||
update_cache_repository: UpdateCacheRepository | None = None,
|
||||
current_version: str = CORE_VERSION,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
|
|
@ -108,12 +114,13 @@ class VibeApp(App):
|
|||
self._current_bottom_app: BottomApp = BottomApp.Input
|
||||
self.theme = config.textual_theme
|
||||
|
||||
self.history_file = HISTORY_FILE
|
||||
self.history_file = HISTORY_FILE.path
|
||||
|
||||
self._tools_collapsed = True
|
||||
self._todos_collapsed = False
|
||||
self._current_streaming_message: AssistantMessage | None = None
|
||||
self._version_update_notifier = version_update_notifier
|
||||
self._update_cache_repository = update_cache_repository
|
||||
self._is_update_check_enabled = config.enable_update_checks
|
||||
self._current_version = current_version
|
||||
self._update_notification_task: asyncio.Task | None = None
|
||||
|
|
@ -126,6 +133,7 @@ class VibeApp(App):
|
|||
# prevent a race condition where the agent initialization
|
||||
# completes exactly at the moment the user interrupts
|
||||
self._agent_init_interrupted = False
|
||||
self._auto_scroll = True
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with VerticalScroll(id="chat"):
|
||||
|
|
@ -460,7 +468,7 @@ class VibeApp(App):
|
|||
|
||||
async def _approval_callback(
|
||||
self, tool: str, args: dict, tool_call_id: str
|
||||
) -> tuple[str, str | None]:
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
self._pending_approval = asyncio.Future()
|
||||
await self._switch_to_approval_app(tool, args)
|
||||
result = await self._pending_approval
|
||||
|
|
@ -955,6 +963,23 @@ class VibeApp(App):
|
|||
|
||||
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:
|
||||
is_dangerous, reason = is_dangerous_directory()
|
||||
if is_dangerous:
|
||||
|
|
@ -975,6 +1000,9 @@ class VibeApp(App):
|
|||
chat = self.query_one("#chat", VerticalScroll)
|
||||
was_at_bottom = self._is_scrolled_to_bottom(chat)
|
||||
|
||||
if was_at_bottom:
|
||||
self._auto_scroll = True
|
||||
|
||||
if isinstance(widget, AssistantMessage):
|
||||
if self._current_streaming_message is not None:
|
||||
content = widget._content or ""
|
||||
|
|
@ -1014,6 +1042,8 @@ class VibeApp(App):
|
|||
self.call_after_refresh(self._scroll_to_bottom)
|
||||
|
||||
def _anchor_if_scrollable(self) -> None:
|
||||
if not self._auto_scroll:
|
||||
return
|
||||
try:
|
||||
chat = self.query_one("#chat", VerticalScroll)
|
||||
if chat.max_scroll_y == 0:
|
||||
|
|
@ -1036,11 +1066,16 @@ class VibeApp(App):
|
|||
|
||||
async def _check_version_update(self) -> None:
|
||||
try:
|
||||
if self._version_update_notifier is None:
|
||||
if (
|
||||
self._version_update_notifier is None
|
||||
or self._update_cache_repository is None
|
||||
):
|
||||
return
|
||||
|
||||
update = await is_version_update_available(
|
||||
self._version_update_notifier, current_version=self._current_version
|
||||
update = await get_update_if_available(
|
||||
version_update_notifier=self._version_update_notifier,
|
||||
current_version=self._current_version,
|
||||
update_cache_repository=self._update_cache_repository,
|
||||
)
|
||||
except VersionUpdateError as error:
|
||||
self.notify(
|
||||
|
|
@ -1056,12 +1091,12 @@ class VibeApp(App):
|
|||
finally:
|
||||
self._update_notification_task = None
|
||||
|
||||
if update is None:
|
||||
if update is None or not update.should_notify:
|
||||
return
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
|
|
@ -1084,9 +1119,8 @@ def run_textual_ui(
|
|||
loaded_messages: list[LLMMessage] | None = None,
|
||||
session_info: ResumeSessionInfo | None = None,
|
||||
) -> None:
|
||||
update_notifier = GitHubVersionUpdateGateway(
|
||||
owner="mistralai", repository="mistral-vibe", token=os.getenv("GITHUB_TOKEN")
|
||||
)
|
||||
update_notifier = PyPIVersionUpdateGateway(project_name="mistral-vibe")
|
||||
update_cache_repository = FileSystemUpdateCacheRepository()
|
||||
app = VibeApp(
|
||||
config=config,
|
||||
auto_approve=auto_approve,
|
||||
|
|
@ -1095,5 +1129,6 @@ def run_textual_ui(
|
|||
loaded_messages=loaded_messages,
|
||||
session_info=session_info,
|
||||
version_update_notifier=update_notifier,
|
||||
update_cache_repository=update_cache_repository,
|
||||
)
|
||||
app.run()
|
||||
|
|
|
|||
|
|
@ -1,31 +1,43 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.update_notifier.fake_version_update_gateway import (
|
||||
FakeVersionUpdateGateway,
|
||||
from vibe.cli.update_notifier.adapters.filesystem_update_cache_repository import (
|
||||
FileSystemUpdateCacheRepository,
|
||||
)
|
||||
from vibe.cli.update_notifier.github_version_update_gateway import (
|
||||
from vibe.cli.update_notifier.adapters.github_version_update_gateway import (
|
||||
GitHubVersionUpdateGateway,
|
||||
)
|
||||
from vibe.cli.update_notifier.version_update import (
|
||||
VersionUpdateError,
|
||||
is_version_update_available,
|
||||
from vibe.cli.update_notifier.adapters.pypi_version_update_gateway import (
|
||||
PyPIVersionUpdateGateway,
|
||||
)
|
||||
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,
|
||||
VersionUpdate,
|
||||
VersionUpdateGateway,
|
||||
VersionUpdateGatewayCause,
|
||||
VersionUpdateGatewayError,
|
||||
)
|
||||
from vibe.cli.update_notifier.version_update import (
|
||||
VersionUpdateAvailability,
|
||||
VersionUpdateError,
|
||||
get_update_if_available,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_GATEWAY_MESSAGES",
|
||||
"FakeVersionUpdateGateway",
|
||||
"FileSystemUpdateCacheRepository",
|
||||
"GitHubVersionUpdateGateway",
|
||||
"PyPIVersionUpdateGateway",
|
||||
"UpdateCache",
|
||||
"UpdateCacheRepository",
|
||||
"VersionUpdate",
|
||||
"VersionUpdateAvailability",
|
||||
"VersionUpdateError",
|
||||
"VersionUpdateGateway",
|
||||
"VersionUpdateGatewayCause",
|
||||
"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
|
||||
|
||||
from vibe.cli.update_notifier.version_update_gateway import (
|
||||
from vibe.cli.update_notifier.ports.version_update_gateway import (
|
||||
VersionUpdate,
|
||||
VersionUpdateGateway,
|
||||
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 enum import StrEnum, auto
|
||||
from typing import Protocol, runtime_checkable
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -49,6 +49,5 @@ class VersionUpdateGatewayError(Exception):
|
|||
super().__init__(detail)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class VersionUpdateGateway(Protocol):
|
||||
async def fetch_update(self) -> VersionUpdate | None: ...
|
||||
|
|
@ -1,15 +1,28 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import time
|
||||
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from vibe.cli.update_notifier.version_update_gateway import (
|
||||
from vibe.cli.update_notifier import (
|
||||
DEFAULT_GATEWAY_MESSAGES,
|
||||
VersionUpdate,
|
||||
UpdateCache,
|
||||
UpdateCacheRepository,
|
||||
VersionUpdateGateway,
|
||||
VersionUpdateGatewayCause,
|
||||
VersionUpdateGatewayError,
|
||||
)
|
||||
|
||||
UPDATE_CACHE_TTL_SECONDS = 24 * 60 * 60
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VersionUpdateAvailability:
|
||||
latest_version: str
|
||||
should_notify: bool
|
||||
|
||||
|
||||
class VersionUpdateError(Exception):
|
||||
def __init__(self, message: str) -> None:
|
||||
|
|
@ -37,21 +50,76 @@ def _describe_gateway_error(error: VersionUpdateGatewayError) -> str:
|
|||
return DEFAULT_GATEWAY_MESSAGES[VersionUpdateGatewayCause.UNKNOWN]
|
||||
|
||||
|
||||
async def is_version_update_available(
|
||||
version_update_notifier: VersionUpdateGateway, current_version: str
|
||||
) -> VersionUpdate | None:
|
||||
def _is_cache_fresh(
|
||||
cache: UpdateCache, get_current_timestamp: Callable[[], int]
|
||||
) -> 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:
|
||||
update = await version_update_notifier.fetch_update()
|
||||
except VersionUpdateGatewayError as error:
|
||||
await _write_update_cache(
|
||||
update_cache_repository, current_version, get_current_timestamp
|
||||
)
|
||||
raise VersionUpdateError(_describe_gateway_error(error)) from error
|
||||
|
||||
if not update:
|
||||
await _write_update_cache(
|
||||
update_cache_repository, current_version, get_current_timestamp
|
||||
)
|
||||
return None
|
||||
|
||||
latest_version = _parse_version(update.latest_version)
|
||||
current = _parse_version(current_version)
|
||||
|
||||
if latest_version is None or current is None:
|
||||
if not (latest_version := _parse_version(update.latest_version)):
|
||||
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
|
||||
|
||||
__all__ = ["__version__", "run_programmatic"]
|
||||
__version__ = "1.1.2"
|
||||
__version__ = "1.1.3"
|
||||
|
||||
from vibe.core.programmatic import run_programmatic
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ from vibe.core.tools.manager import ToolManager
|
|||
from vibe.core.types import (
|
||||
AgentStats,
|
||||
ApprovalCallback,
|
||||
ApprovalResponse,
|
||||
AssistantEvent,
|
||||
AsyncApprovalCallback,
|
||||
BaseEvent,
|
||||
CompactEndEvent,
|
||||
CompactStartEvent,
|
||||
|
|
@ -53,7 +55,6 @@ from vibe.core.types import (
|
|||
from vibe.core.utils import (
|
||||
TOOL_ERROR_TAG,
|
||||
VIBE_STOP_EVENT_TAG,
|
||||
ApprovalResponse,
|
||||
CancellationReason,
|
||||
get_user_agent,
|
||||
get_user_cancellation_message,
|
||||
|
|
@ -188,11 +189,6 @@ class Agent:
|
|||
case MiddlewareAction.STOP:
|
||||
yield AssistantEvent(
|
||||
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,
|
||||
)
|
||||
await self.interaction_logger.save_interaction(
|
||||
|
|
@ -337,16 +333,7 @@ class Agent:
|
|||
def _create_assistant_event(
|
||||
self, content: str, chunk: LLMChunk | None
|
||||
) -> AssistantEvent:
|
||||
return AssistantEvent(
|
||||
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,
|
||||
)
|
||||
return AssistantEvent(content=content)
|
||||
|
||||
async def _stream_assistant_events(self) -> AsyncGenerator[AssistantEvent]:
|
||||
chunks: list[LLMChunk] = []
|
||||
|
|
@ -421,14 +408,7 @@ class Agent:
|
|||
assistant_msg = llm_result.message
|
||||
self.messages.append(assistant_msg)
|
||||
|
||||
return AssistantEvent(
|
||||
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,
|
||||
)
|
||||
return AssistantEvent(content=assistant_msg.content or "")
|
||||
|
||||
async def _handle_tool_calls( # noqa: PLR0915
|
||||
self, resolved: ResolvedMessage
|
||||
|
|
@ -761,24 +741,18 @@ class Agent:
|
|||
feedback="Tool execution not permitted.",
|
||||
)
|
||||
if asyncio.iscoroutinefunction(self.approval_callback):
|
||||
response, feedback = await self.approval_callback(
|
||||
tool_name, args, tool_call_id
|
||||
)
|
||||
async_callback = cast(AsyncApprovalCallback, self.approval_callback)
|
||||
response, feedback = await async_callback(tool_name, args, tool_call_id)
|
||||
else:
|
||||
sync_callback = cast(SyncApprovalCallback, self.approval_callback)
|
||||
response, feedback = sync_callback(tool_name, args, tool_call_id)
|
||||
|
||||
match response:
|
||||
case ApprovalResponse.ALWAYS:
|
||||
self.auto_approve = True
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.EXECUTE, feedback=feedback
|
||||
)
|
||||
case ApprovalResponse.YES:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.EXECUTE, feedback=feedback
|
||||
)
|
||||
case _:
|
||||
case ApprovalResponse.NO:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.SKIP, feedback=feedback
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,45 +19,28 @@ from pydantic_settings import (
|
|||
)
|
||||
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.tools.base import BaseToolConfig
|
||||
|
||||
|
||||
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
|
||||
PROJECT_DOC_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"]
|
||||
|
||||
|
||||
def load_api_keys_from_env() -> None:
|
||||
if GLOBAL_ENV_FILE.is_file():
|
||||
env_vars = dotenv_values(GLOBAL_ENV_FILE)
|
||||
if GLOBAL_ENV_FILE.path.is_file():
|
||||
env_vars = dotenv_values(GLOBAL_ENV_FILE.path)
|
||||
for key, value in env_vars.items():
|
||||
if 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):
|
||||
def __init__(self, env_key: str, provider_name: str) -> None:
|
||||
super().__init__(
|
||||
|
|
@ -94,7 +77,7 @@ class TomlFileSettingsSource(PydanticBaseSettingsSource):
|
|||
self.toml_data = self._load_toml()
|
||||
|
||||
def _load_toml(self) -> dict[str, Any]:
|
||||
file = CONFIG_FILE
|
||||
file = CONFIG_FILE.path
|
||||
try:
|
||||
with file.open("rb") as f:
|
||||
return tomllib.load(f)
|
||||
|
|
@ -134,7 +117,7 @@ class SessionLoggingConfig(BaseSettings):
|
|||
@classmethod
|
||||
def set_default_save_dir(cls, v: str) -> str:
|
||||
if not v:
|
||||
return str(get_vibe_home() / "logs" / "session")
|
||||
return str(SESSION_LOG_DIR.path)
|
||||
return v
|
||||
|
||||
@field_validator("save_dir", mode="after")
|
||||
|
|
@ -350,7 +333,7 @@ class VibeConfig(BaseSettings):
|
|||
)
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="VIBE_", case_sensitive=False, extra="forbid"
|
||||
env_prefix="VIBE_", case_sensitive=False, extra="ignore"
|
||||
)
|
||||
|
||||
@property
|
||||
|
|
@ -364,9 +347,9 @@ class VibeConfig(BaseSettings):
|
|||
except KeyError:
|
||||
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():
|
||||
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()
|
||||
|
||||
def get_active_model(self) -> ModelConfig:
|
||||
|
|
@ -492,7 +475,7 @@ class VibeConfig(BaseSettings):
|
|||
|
||||
@classmethod
|
||||
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
|
||||
|
||||
def deep_merge(target: dict, source: dict) -> None:
|
||||
|
|
@ -522,7 +505,7 @@ class VibeConfig(BaseSettings):
|
|||
|
||||
@classmethod
|
||||
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)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -530,21 +513,21 @@ class VibeConfig(BaseSettings):
|
|||
if agent is None:
|
||||
return None
|
||||
|
||||
agent_config_path = (AGENT_DIR / agent).with_suffix(".toml")
|
||||
agent_config_path = (AGENT_DIR.path / agent).with_suffix(".toml")
|
||||
try:
|
||||
return tomllib.load(agent_config_path.open("rb"))
|
||||
except FileNotFoundError:
|
||||
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
|
||||
def _migrate(cls) -> None:
|
||||
if not CONFIG_FILE.exists():
|
||||
if not CONFIG_FILE.path.is_file():
|
||||
return
|
||||
|
||||
try:
|
||||
with CONFIG_FILE.open("rb") as f:
|
||||
with CONFIG_FILE.path.open("rb") as f:
|
||||
config = tomllib.load(f)
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
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:
|
||||
payload["stream"] = True
|
||||
stream_options = {"include_usage": True}
|
||||
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)
|
||||
|
||||
|
|
@ -141,11 +143,11 @@ class OpenAIAdapter(APIAdapter):
|
|||
message = LLMMessage.model_validate(data["choices"][0]["delta"])
|
||||
else:
|
||||
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:
|
||||
message = LLMMessage.model_validate(data["message"])
|
||||
finish_reason = data["finish_reason"]
|
||||
finish_reason = data["choices"][0].get("finish_reason", None)
|
||||
elif "delta" in data:
|
||||
message = LLMMessage.model_validate(data["delta"])
|
||||
finish_reason = None
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import sys
|
|||
import time
|
||||
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.prompts import UtilityPrompt
|
||||
from vibe.core.utils import is_dangerous_directory, is_windows
|
||||
|
|
@ -21,7 +22,7 @@ if TYPE_CHECKING:
|
|||
|
||||
def _load_user_instructions() -> str:
|
||||
try:
|
||||
return INSTRUCTIONS_FILE.read_text("utf-8", errors="ignore")
|
||||
return INSTRUCTIONS_FILE.path.read_text("utf-8", errors="ignore")
|
||||
except (FileNotFoundError, OSError):
|
||||
return ""
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import sys
|
|||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
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.mcp import (
|
||||
RemoteTool,
|
||||
|
|
@ -60,16 +60,11 @@ class ToolManager:
|
|||
if path.is_dir():
|
||||
paths.append(path)
|
||||
|
||||
cwd = config.effective_workdir
|
||||
for directory in (cwd, *cwd.parents):
|
||||
tools_dir = directory / ".vibe" / "tools"
|
||||
if tools_dir.is_dir():
|
||||
if (tools_dir := resolve_local_tools_dir(config.effective_workdir)) is not None:
|
||||
paths.append(tools_dir)
|
||||
break
|
||||
|
||||
global_tools = get_vibe_home() / "tools"
|
||||
if global_tools.is_dir():
|
||||
paths.append(global_tools)
|
||||
if GLOBAL_TOOLS_DIR.path.is_dir():
|
||||
paths.append(GLOBAL_TOOLS_DIR.path)
|
||||
|
||||
unique: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
|
|
|
|||
|
|
@ -173,6 +173,11 @@ class Role(StrEnum):
|
|||
tool = auto()
|
||||
|
||||
|
||||
class ApprovalResponse(StrEnum):
|
||||
YES = "y"
|
||||
NO = "n"
|
||||
|
||||
|
||||
class LLMMessage(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
|
@ -219,11 +224,6 @@ class BaseEvent(BaseModel, ABC):
|
|||
|
||||
class AssistantEvent(BaseEvent):
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -263,9 +263,11 @@ class OutputFormat(StrEnum):
|
|||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine
|
||||
import concurrent.futures
|
||||
from enum import Enum, StrEnum, auto
|
||||
from enum import Enum, auto
|
||||
import functools
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
|
@ -14,16 +14,10 @@ from typing import Any
|
|||
import httpx
|
||||
|
||||
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
|
||||
|
||||
|
||||
class ApprovalResponse(StrEnum):
|
||||
YES = "y"
|
||||
NO = "n"
|
||||
ALWAYS = "a"
|
||||
|
||||
|
||||
CANCELLATION_TAG = "user_cancellation"
|
||||
TOOL_ERROR_TAG = "tool_error"
|
||||
VIBE_STOP_EVENT_TAG = "vibe_stop_event"
|
||||
|
|
@ -141,22 +135,21 @@ def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]:
|
|||
return False, ""
|
||||
|
||||
|
||||
LOG_DIR = CONFIG_DIR
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
LOG_FILE = LOG_DIR / "vibe.log"
|
||||
LOG_DIR.path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
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.info("Using config: %s", CONFIG_FILE)
|
||||
if CONFIG_FILE != GLOBAL_CONFIG_FILE and GLOBAL_CONFIG_FILE.is_file():
|
||||
logger.info("Using config: %s", CONFIG_FILE.path)
|
||||
if CONFIG_FILE.path != GLOBAL_CONFIG_FILE.path and GLOBAL_CONFIG_FILE.path.is_file():
|
||||
logger.warning(
|
||||
"Project config active (%s); ignoring global config (%s)",
|
||||
CONFIG_FILE,
|
||||
GLOBAL_CONFIG_FILE,
|
||||
CONFIG_FILE.path,
|
||||
GLOBAL_CONFIG_FILE.path,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import sys
|
|||
from rich import print as rprint
|
||||
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 (
|
||||
ApiKeyScreen,
|
||||
ThemeSelectionScreen,
|
||||
|
|
@ -34,7 +34,7 @@ def run_onboarding(app: App | None = None) -> None:
|
|||
rprint(
|
||||
f"\n[yellow]Warning: Could not save API key to .env file: {err}[/]"
|
||||
"\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":
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ from textual.validation import Length
|
|||
from textual.widgets import Input, Link, Static
|
||||
|
||||
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
|
||||
|
||||
PROVIDER_HELP = {
|
||||
|
|
@ -24,8 +25,8 @@ CONFIG_DOCS_URL = (
|
|||
|
||||
|
||||
def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
|
||||
GLOBAL_ENV_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
set_key(GLOBAL_ENV_FILE, env_key, api_key)
|
||||
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
set_key(GLOBAL_ENV_FILE.path, env_key, api_key)
|
||||
|
||||
|
||||
class ApiKeyScreen(OnboardingScreen):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue