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:
Mathias Gesbert 2025-12-12 17:47:20 +01:00 committed by Mathias Gesbert
parent a340a721ea
commit 661588de0c
48 changed files with 1679 additions and 467 deletions

View file

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

@ -1,5 +1,5 @@
{
"version": "1.1.2",
"version": "1.1.3",
"configurations": [
{
"name": "ACP Server",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

@ -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",
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

@ -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" },

View file

@ -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"})

View file

@ -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)
try:
pyperclip.copy(combined_text)
app.notify("Selection added to clipboard", severity="information", timeout=2)
except Exception:
for copy_fn in [_copy_osc52, pyperclip.copy, app.copy_to_clipboard]:
try:
copy_fn(combined_text)
except:
pass
else:
app.notify(
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
severity="information",
timeout=2,
)
break
else:
app.notify(
"Use Ctrl+c to copy selections in Vibe", severity="warning", timeout=3
"Failed to copy - no clipboard method available",
severity="warning",
timeout=3,
)

View file

@ -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}[/]")

View file

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

View file

@ -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",
]

View file

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

View file

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

View 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

View 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: ...

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View 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")

View file

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

View file

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

View file

@ -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():
paths.append(tools_dir)
break
if (tools_dir := resolve_local_tools_dir(config.effective_workdir)) is not None:
paths.append(tools_dir)
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()

View file

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

View file

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

View file

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

View file

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