diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml index 8a281ab..09699e3 100644 --- a/.github/workflows/build-and-upload.yml +++ b/.github/workflows/build-and-upload.yml @@ -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 diff --git a/.vscode/launch.json b/.vscode/launch.json index 58582f5..380cab1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,5 +1,5 @@ { - "version": "1.1.2", + "version": "1.1.3", "configurations": [ { "name": "ACP Server", diff --git a/CHANGELOG.md b/CHANGELOG.md index d7bc96b..f5d2249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.3] - 2025-12-12 + +### Added + +- Add more copy_to_clipboard methods to support all cases +- Add bindings to scroll chat history + +### Changed + +- Relax config to accept extra inputs +- Remove useless stats from assistant events +- Improve scroll actions while streaming +- Do not check for updates more than once a day +- Use PyPI in update notifier + +### Fixed + +- Fix tool permission handling for "allow always" option in ACP +- Fix security issue: prevent command injection in GitHub Action prompt handling +- Fix issues with vLLM + ## [1.1.2] - 2025-12-11 ### Changed diff --git a/action.yml b/action.yml index 8044a4a..33f0d55 100644 --- a/action.yml +++ b/action.yml @@ -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" diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index f068102..c701625 100644 --- a/distribution/zed/extension.toml +++ b/distribution/zed/extension.toml @@ -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" diff --git a/pyproject.toml b/pyproject.toml index b3eb550..2ec8c7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/tests/acp/test_acp.py b/tests/acp/test_acp.py index 3b1fb4d..c191cdc 100644 --- a/tests/acp/test_acp.py +++ b/tests/acp/test_acp.py @@ -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" ) diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index eb76551..221fa1d 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -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 diff --git a/tests/acp/test_set_model.py b/tests/acp/test_set_model.py index d9a0ea5..fde444f 100644 --- a/tests/acp/test_set_model.py +++ b/tests/acp/test_set_model.py @@ -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}) diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py index 0ccdac7..a29c89b 100644 --- a/tests/backend/test_backend.py +++ b/tests/backend/test_backend.py @@ -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): diff --git a/tests/cli/test_clipboard.py b/tests/cli/test_clipboard.py new file mode 100644 index 0000000..8d505db --- /dev/null +++ b/tests/cli/test_clipboard.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py index 3fa0537..a9dce74 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) diff --git a/tests/core/test_config_resolution.py b/tests/core/test_config_resolution.py new file mode 100644 index 0000000..c69e47a --- /dev/null +++ b/tests/core/test_config_resolution.py @@ -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 diff --git a/tests/onboarding/test_run_onboarding.py b/tests/onboarding/test_run_onboarding.py index 4f2a4db..e315a77 100644 --- a/tests/onboarding/test_run_onboarding.py +++ b/tests/onboarding/test_run_onboarding.py @@ -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")) diff --git a/tests/onboarding/test_ui_onboarding.py b/tests/onboarding/test_ui_onboarding.py index 535a98a..06017b3 100644 --- a/tests/onboarding/test_ui_onboarding.py +++ b/tests/onboarding/test_ui_onboarding.py @@ -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 diff --git a/tests/snapshots/test_ui_snapshot_release_update_notification.py b/tests/snapshots/test_ui_snapshot_release_update_notification.py index 6a8be15..f15fe4e 100644 --- a/tests/snapshots/test_ui_snapshot_release_update_notification.py +++ b/tests/snapshots/test_ui_snapshot_release_update_notification.py @@ -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", ) diff --git a/tests/test_agent_observer_streaming.py b/tests/test_agent_observer_streaming.py index 0adc108..148c99e 100644 --- a/tests/test_agent_observer_streaming.py +++ b/tests/test_agent_observer_streaming.py @@ -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([ diff --git a/tests/test_agent_tool_call.py b/tests/test_agent_tool_call.py index 2230ad6..f511bb6 100644 --- a/tests/test_agent_tool_call.py +++ b/tests/test_agent_tool_call.py @@ -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) diff --git a/tests/update_notifier/adapters/fake_update_cache_repository.py b/tests/update_notifier/adapters/fake_update_cache_repository.py new file mode 100644 index 0000000..b33491d --- /dev/null +++ b/tests/update_notifier/adapters/fake_update_cache_repository.py @@ -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 diff --git a/vibe/cli/update_notifier/fake_version_update_gateway.py b/tests/update_notifier/adapters/fake_version_update_gateway.py similarity index 90% rename from vibe/cli/update_notifier/fake_version_update_gateway.py rename to tests/update_notifier/adapters/fake_version_update_gateway.py index 8baac50..957bd7b 100644 --- a/vibe/cli/update_notifier/fake_version_update_gateway.py +++ b/tests/update_notifier/adapters/fake_version_update_gateway.py @@ -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, diff --git a/tests/update_notifier/test_filesystem_update_cache_repository.py b/tests/update_notifier/test_filesystem_update_cache_repository.py new file mode 100644 index 0000000..fa6c511 --- /dev/null +++ b/tests/update_notifier/test_filesystem_update_cache_repository.py @@ -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() diff --git a/tests/update_notifier/test_github_version_update_gateway.py b/tests/update_notifier/test_github_version_update_gateway.py index df3861c..cf59668 100644 --- a/tests/update_notifier/test_github_version_update_gateway.py +++ b/tests/update_notifier/test_github_version_update_gateway.py @@ -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, ) diff --git a/tests/update_notifier/test_pypi_version_update_gateway.py b/tests/update_notifier/test_pypi_version_update_gateway.py new file mode 100644 index 0000000..1bd3cc4 --- /dev/null +++ b/tests/update_notifier/test_pypi_version_update_gateway.py @@ -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 diff --git a/tests/update_notifier/test_ui_version_update_notification.py b/tests/update_notifier/test_ui_version_update_notification.py index 91a60ca..3b3c3b6 100644 --- a/tests/update_notifier/test_ui_version_update_notification.py +++ b/tests/update_notifier/test_ui_version_update_notification.py @@ -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 diff --git a/tests/update_notifier/test_version_update_use_case.py b/tests/update_notifier/test_version_update_use_case.py index 85e7195..6a2bbb1 100644 --- a/tests/update_notifier/test_version_update_use_case.py +++ b/tests/update_notifier/test_version_update_use_case.py @@ -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 diff --git a/uv.lock b/uv.lock index cbffd62..fbda8a8 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, diff --git a/vibe/acp/acp_agent.py b/vibe/acp/acp_agent.py index c30246d..0313723 100644 --- a/vibe/acp/acp_agent.py +++ b/vibe/acp/acp_agent.py @@ -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"}) diff --git a/vibe/cli/clipboard.py b/vibe/cli/clipboard.py index 429f097..1b60b7b 100644 --- a/vibe/cli/clipboard.py +++ b/vibe/cli/clipboard.py @@ -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, ) diff --git a/vibe/cli/entrypoint.py b/vibe/cli/entrypoint.py index 8eb93f4..50f8583 100644 --- a/vibe/cli/entrypoint.py +++ b/vibe/cli/entrypoint.py @@ -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}[/]") diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index b0544c7..94f8b45 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -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() diff --git a/vibe/cli/update_notifier/__init__.py b/vibe/cli/update_notifier/__init__.py index 7727e0b..804a827 100644 --- a/vibe/cli/update_notifier/__init__.py +++ b/vibe/cli/update_notifier/__init__.py @@ -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", ] diff --git a/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py b/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py new file mode 100644 index 0000000..1f455e4 --- /dev/null +++ b/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py @@ -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 diff --git a/vibe/cli/update_notifier/github_version_update_gateway.py b/vibe/cli/update_notifier/adapters/github_version_update_gateway.py similarity index 98% rename from vibe/cli/update_notifier/github_version_update_gateway.py rename to vibe/cli/update_notifier/adapters/github_version_update_gateway.py index 86bbbc4..591afe4 100644 --- a/vibe/cli/update_notifier/github_version_update_gateway.py +++ b/vibe/cli/update_notifier/adapters/github_version_update_gateway.py @@ -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, diff --git a/vibe/cli/update_notifier/adapters/pypi_version_update_gateway.py b/vibe/cli/update_notifier/adapters/pypi_version_update_gateway.py new file mode 100644 index 0000000..d4038d6 --- /dev/null +++ b/vibe/cli/update_notifier/adapters/pypi_version_update_gateway.py @@ -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 diff --git a/vibe/cli/update_notifier/ports/update_cache_repository.py b/vibe/cli/update_notifier/ports/update_cache_repository.py new file mode 100644 index 0000000..6806746 --- /dev/null +++ b/vibe/cli/update_notifier/ports/update_cache_repository.py @@ -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: ... diff --git a/vibe/cli/update_notifier/version_update_gateway.py b/vibe/cli/update_notifier/ports/version_update_gateway.py similarity index 96% rename from vibe/cli/update_notifier/version_update_gateway.py rename to vibe/cli/update_notifier/ports/version_update_gateway.py index 9e5e60c..762546f 100644 --- a/vibe/cli/update_notifier/version_update_gateway.py +++ b/vibe/cli/update_notifier/ports/version_update_gateway.py @@ -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: ... diff --git a/vibe/cli/update_notifier/version_update.py b/vibe/cli/update_notifier/version_update.py index 55445e0..f5a65f9 100644 --- a/vibe/cli/update_notifier/version_update.py +++ b/vibe/cli/update_notifier/version_update.py @@ -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 + ) diff --git a/vibe/core/__init__.py b/vibe/core/__init__.py index 55e1056..b75f299 100644 --- a/vibe/core/__init__.py +++ b/vibe/core/__init__.py @@ -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 diff --git a/vibe/core/agent.py b/vibe/core/agent.py index c2da6c7..4538021 100644 --- a/vibe/core/agent.py +++ b/vibe/core/agent.py @@ -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}", - 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 ) diff --git a/vibe/core/config.py b/vibe/core/config.py index f45121d..12eedef 100644 --- a/vibe/core/config.py +++ b/vibe/core/config.py @@ -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 diff --git a/vibe/core/config_path.py b/vibe/core/config_path.py new file mode 100644 index 0000000..d88ab35 --- /dev/null +++ b/vibe/core/config_path.py @@ -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") diff --git a/vibe/core/llm/backend/generic.py b/vibe/core/llm/backend/generic.py index 2b8134a..4abe4f0 100644 --- a/vibe/core/llm/backend/generic.py +++ b/vibe/core/llm/backend/generic.py @@ -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 diff --git a/vibe/core/system_prompt.py b/vibe/core/system_prompt.py index 2de6e11..c8bb565 100644 --- a/vibe/core/system_prompt.py +++ b/vibe/core/system_prompt.py @@ -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 "" diff --git a/vibe/core/tools/manager.py b/vibe/core/tools/manager.py index 4020616..56c0b78 100644 --- a/vibe/core/tools/manager.py +++ b/vibe/core/tools/manager.py @@ -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() diff --git a/vibe/core/types.py b/vibe/core/types.py index c537a18..bd18fff 100644 --- a/vibe/core/types.py +++ b/vibe/core/types.py @@ -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 diff --git a/vibe/core/utils.py b/vibe/core/utils.py index 5121cdd..84c7247 100644 --- a/vibe/core/utils.py +++ b/vibe/core/utils.py @@ -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, ) diff --git a/vibe/setup/onboarding/__init__.py b/vibe/setup/onboarding/__init__.py index b92aee2..c11c086 100644 --- a/vibe/setup/onboarding/__init__.py +++ b/vibe/setup/onboarding/__init__.py @@ -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 diff --git a/vibe/setup/onboarding/screens/api_key.py b/vibe/setup/onboarding/screens/api_key.py index 65df1bb..fb3c50a 100644 --- a/vibe/setup/onboarding/screens/api_key.py +++ b/vibe/setup/onboarding/screens/api_key.py @@ -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):