diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml index 5f0be12..164e17d 100644 --- a/.github/workflows/build-and-upload.yml +++ b/.github/workflows/build-and-upload.yml @@ -29,13 +29,16 @@ jobs: {"runner": "windows-2022", "os": "windows", "arch": "x86_64"} ] }' + # TODO: Re-enable macOS standalone artifact smoke tests once the PyInstaller + # binaries are Developer ID signed and notarized. The direct GitHub zip + # download path gets Gatekeeper-assessed via quarantine and currently rejects + # ad-hoc signed binaries, even though vibe-acp continues to work when bundled + # through the VS Code extension. smoke_matrix='{ "include": [ {"runner": "ubuntu-24.04", "os": "linux", "arch": "x86_64"}, {"runner": "ubuntu-24.04", "os": "linux", "arch": "x86_64", "tag": "old-glibc", "container": "almalinux:8"}, {"runner": "ubuntu-24.04-arm", "os": "linux", "arch": "aarch64"}, - {"runner": "macos-15", "os": "darwin", "arch": "aarch64"}, - {"runner": "macos-15-intel", "os": "darwin", "arch": "x86_64"}, {"runner": "windows-latest", "os": "windows", "arch": "x86_64"} ] }' @@ -84,27 +87,39 @@ jobs: - name: Sync dependencies run: uv sync --no-dev --group build - - name: Build with PyInstaller + - name: Build ACP with PyInstaller run: uv run --no-dev --group build pyinstaller vibe-acp.spec + - name: Build CLI with PyInstaller + run: uv run --no-dev --group build pyinstaller vibe.spec + - name: Clear executable stack on bundled libraries if: ${{ matrix.os == 'linux' }} run: | find dist/vibe-acp-dir/_internal -name '*.so*' -type f -print0 \ | xargs -0 -I{} patchelf --clear-execstack {} patchelf --clear-execstack dist/vibe-acp-dir/vibe-acp || true + find dist/vibe-dir/_internal -name '*.so*' -type f -print0 \ + | xargs -0 -I{} patchelf --clear-execstack {} + patchelf --clear-execstack dist/vibe-dir/vibe || true - name: Get package version id: get_version shell: bash run: echo "version=$(uv version | cut -d' ' -f2)" >> $GITHUB_OUTPUT - - name: Upload binary as artifact + - name: Upload ACP binary as artifact uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.get_version.outputs.version }} path: dist/vibe-acp-dir/ + - name: Upload CLI binary as artifact + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: vibe-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.get_version.outputs.version }} + path: dist/vibe-dir/ + nix-build: needs: configure name: "Nix build and upload: ${{ matrix.os }}-${{ matrix.arch }}" @@ -159,16 +174,23 @@ jobs: run: yum install -y python3.11 python3.11-pip - name: Install smoke test deps - run: ${{ matrix.container && 'python3.11' || 'python' }} -m pip install agent-client-protocol==0.9.0 + run: ${{ matrix.container && 'python3.11' || 'python' }} -m pip install agent-client-protocol==0.10.1 - - name: Download artifact + - name: Download ACP artifact uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 with: pattern: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-* merge-multiple: true path: dist/vibe-acp-dir - - name: Run smoke tests + - name: Download CLI artifact + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + pattern: vibe-${{ matrix.os }}-${{ matrix.arch }}-* + merge-multiple: true + path: dist/vibe-dir + + - name: Run ACP smoke tests run: ${{ matrix.container && 'python3.11' || 'python' }} tests/acp/smoke_binary.py dist/vibe-acp-dir attach-to-release: @@ -191,6 +213,9 @@ jobs: if [ -f "$dir/vibe-acp" ] || [ -f "$dir/vibe-acp.exe" ]; then chmod -f +x "$dir/vibe-acp" 2>/dev/null || true (cd "$dir" && zip -r "../../release-assets/${name}.zip" .) + elif [ -f "$dir/vibe" ] || [ -f "$dir/vibe.exe" ]; then + chmod -f +x "$dir/vibe" 2>/dev/null || true + (cd "$dir" && zip -r "../../release-assets/${name}.zip" .) fi done diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 776db61..f551384 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: - id: pyright - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.5 + rev: v0.15.13 hooks: - id: ruff-check args: [--fix, --unsafe-fixes] diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1f564..dba0b66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ 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). +## [2.14.0] - 2026-06-04 + +### Added + +- Image attachments via `@`-mentions in the TUI for vision-capable models +- Session deletion, exposed over ACP as the `session/delete` extension method +- Browser sign-in now shows a copyable fallback URL when the browser does not open, so SSH and remote sessions can complete onboarding +- Toast notification when a `SKILL.md` file fails to parse instead of silently dropping the skill +- Trust prompt now proposes the git repository ancestor as a trust target +- `EnvironmentLayer` in the layered configuration, populated from `VIBE_`-prefixed environment variables + +### Changed + +- New tool-call format for `read` and the file `edit` tool +- `write_file` is now create-only and refuses to overwrite existing files +- Bumped `agent-client-protocol` to 0.10.1 + +### Fixed + +- LLM calls now retry on network errors and timeouts (connection, read, write, remote protocol, timeout) +- Approval modal now sizes itself so the bottom of long tool-call payloads stays visible above the options block +- Banner connector count and `/mcp` panel ordering now reflect which connectors are actually usable and the user's enable/disable choices +- `disabled_tools` from runtime is merged with the TOML configuration instead of replacing it, and is enforced inside `ToolManager.get()` +- `shift+backspace` and `shift+delete` now work in the chat input +- Crash in the ACP `todo` plan-update handler when the model's tool call failed validation + +### Removed + +- Recursive search for nested harness files + + ## [2.13.0] - 2026-05-29 ### Added diff --git a/README.md b/README.md index eb4be39..e9f3f94 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ pip install mistral-vibe - **Interactive Chat**: A conversational AI agent that understands your requests and breaks down complex tasks. - **Powerful Toolset**: A suite of tools for file manipulation, code searching, version control, and command execution, right from the chat prompt. - - Read, write, and patch files (`read_file`, `write_file`, `search_replace`). + - Read, write, and patch files (`read`, `write_file`, `edit`). - Execute shell commands in a stateful terminal (`bash`). - Recursively search code with `grep` (with `ripgrep` support). - Manage a `todo` list to track the agent's work. @@ -102,6 +102,7 @@ pip install mistral-vibe - **Project-Aware Context**: Vibe automatically scans your project's file structure and Git status to provide relevant context to the agent, improving its understanding of your codebase. - **Advanced CLI Experience**: Built with modern libraries for a smooth and efficient workflow. - Autocompletion for slash commands (`/`) and file paths (`@`). + - Image attachments via `@` mentions — `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp` files are sent to vision-capable models (e.g. Mistral Medium 3.5) as native multimodal content. - Persistent command history. - Beautiful Themes. - **Highly Configurable**: Customize models, providers, tool permissions, and UI preferences through a simple `config.toml` file. @@ -113,8 +114,8 @@ pip install mistral-vibe Vibe comes with several built-in agent profiles, each designed for different use cases: - **`default`**: Standard agent that requires approval for tool executions. Best for general use. -- **`plan`**: Read-only agent for exploration and planning. Auto-approves safe tools like `grep` and `read_file`. -- **`accept-edits`**: Auto-approves file edits only (`write_file`, `search_replace`). Useful for code refactoring. +- **`plan`**: Read-only agent for exploration and planning. Auto-approves safe tools like `grep` and `read`. +- **`accept-edits`**: Auto-approves file edits only (`write_file`, `edit`). Useful for code refactoring. - **`auto-approve`**: Auto-approves all tool executions. Use with caution. Use the `--agent` flag to select a different agent: @@ -356,7 +357,7 @@ license: MIT compatibility: Python 3.12+ user-invocable: true allowed-tools: - - read_file + - read - grep - ask_user_question --- @@ -491,13 +492,13 @@ active_model = "mistral-medium-3.5" system_prompt_id = "redteam" # Disable some tools for this agent -disabled_tools = ["search_replace", "write_file"] +disabled_tools = ["edit", "write_file"] # Override tool permissions for this agent [tools.bash] permission = "always" -[tools.read_file] +[tools.read] permission = "always" ``` diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index c1437a1..a110bf3 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 = "2.13.0" +version = "2.14.0" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,21 +11,21 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-darwin-aarch64-2.13.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.0/vibe-acp-darwin-aarch64-2.14.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-darwin-x86_64-2.13.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.0/vibe-acp-darwin-x86_64-2.14.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-linux-aarch64-2.13.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.0/vibe-acp-linux-aarch64-2.14.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-linux-x86_64-2.13.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.0/vibe-acp-linux-x86_64-2.14.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-windows-x86_64-2.13.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.0/vibe-acp-windows-x86_64-2.14.0.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index eb8b4ca..7789d53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.13.0" +version = "2.14.0" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" @@ -27,7 +27,7 @@ classifiers = [ "Topic :: Utilities", ] dependencies = [ - "agent-client-protocol==0.9.0", + "agent-client-protocol==0.10.1", "annotated-types==0.7.0", "anyio==4.13.0", "attrs==26.1.0", @@ -49,6 +49,7 @@ dependencies = [ "httpcore==1.0.9", "httpx==0.28.1", "httpx-sse==0.4.3", + "humanize==4.15.0", "idna==3.13", "importlib-metadata==8.7.1", "jaraco-classes==3.4.0", @@ -147,7 +148,6 @@ vibe-acp = "vibe.acp.entrypoint:main" [tool.uv] exclude-newer = "7 days" -exclude-newer-package = { agent-client-protocol = false, mistralai = false, textual = false } package = true required-version = ">=0.8.0" @@ -165,7 +165,7 @@ dev = [ "pytest-timeout>=2.4.0", "pytest-xdist>=3.8.0", "respx>=0.22.0", - "ruff>=0.14.5", + "ruff>=0.15.13", "tomlkit>=0.13.0", "twine>=5.0.0", "typos>=1.34.0", @@ -174,65 +174,13 @@ dev = [ build = [ "altgraph==0.17.5", - "anyio==4.13.0", - "certifi==2026.4.22", - "cffi==2.0.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'", - "cfgv==3.5.0", - "charset-normalizer==3.4.7", - "colorama==0.4.6 ; sys_platform == 'win32'", - "cryptography==47.0.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", - "distlib==0.4.0", - "docutils==0.22.4", - "execnet==2.1.2", - "filelock==3.29.0", - "h11==0.16.0", - "httpcore==1.0.9", - "httpx==0.28.1", - "id==1.6.1", - "identify==2.6.19", - "idna==3.13", - "iniconfig==2.3.0", - "jaraco-classes==3.4.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x'", - "jaraco-context==6.1.2 ; platform_machine != 'ppc64le' and platform_machine != 's390x'", - "jaraco-functools==4.4.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x'", - "jeepney==0.9.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", - "jinja2==3.1.6", - "keyring==25.7.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x'", - "linkify-it-py==2.1.0", "macholib==1.16.4 ; sys_platform == 'darwin'", - "markdown-it-py==4.0.0", - "markupsafe==3.0.3", - "mdit-py-plugins==0.5.0", - "mdurl==0.1.2", - "more-itertools==11.0.2 ; platform_machine != 'ppc64le' and platform_machine != 's390x'", - "nh3==0.3.5", - "nodeenv==1.10.0", "packaging==26.2", "pefile==2024.8.26 ; sys_platform == 'win32'", - "platformdirs==4.9.6", - "pluggy==1.6.0", - "pycparser==3.0 ; implementation_name != 'PyPy' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'", - "pygments==2.20.0", "pyinstaller==6.20.0", "pyinstaller-hooks-contrib==2026.4", - "pyinstrument==5.1.2", - "python-discovery==1.2.2", "pywin32-ctypes==0.2.3 ; sys_platform == 'win32'", - "pyyaml==6.0.3", - "readme-renderer==44.0", - "requests==2.33.1", - "requests-toolbelt==1.0.0", - "rfc3986==2.0.0", - "rich==15.0.0", - "secretstorage==3.5.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", "setuptools==82.0.1", - "syrupy==4.8.0", - "textual==8.2.7", - "truststore==0.10.4", - "typing-extensions==4.15.0", - "uc-micro-py==2.0.0", - "urllib3==2.6.3", - "virtualenv==21.2.4", ] [tool.pyright] diff --git a/tests/acp/test_acp.py b/tests/acp/test_acp.py index aad994e..9a13d69 100644 --- a/tests/acp/test_acp.py +++ b/tests/acp/test_acp.py @@ -101,6 +101,13 @@ def vibe_home_grep_ask(tmp_path: Path) -> Path: return _create_vibe_home_dir(tmp_path, {"tools": {"grep": {"permission": "ask"}}}) +@pytest.fixture +def acp_project_dir(tmp_path: Path) -> Path: + project_dir = tmp_path / "project" + project_dir.mkdir() + return project_dir + + class JsonRpcRequest(BaseModel): jsonrpc: str = "2.0" id: int | str @@ -358,7 +365,10 @@ def parse_conversation(message_texts: list[str]) -> list[JsonRpcMessage]: return parsed_messages -async def initialize_session(acp_agent_loop_process: asyncio.subprocess.Process) -> str: +async def initialize_session( + acp_agent_loop_process: asyncio.subprocess.Process, + session_cwd: Path = PLAYGROUND_DIR, +) -> str: await send_json_rpc( acp_agent_loop_process, InitializeJsonRpcRequest(id=1, params=InitializeRequest(protocol_version=1)), @@ -371,7 +381,7 @@ async def initialize_session(acp_agent_loop_process: asyncio.subprocess.Process) await send_json_rpc( acp_agent_loop_process, NewSessionJsonRpcRequest( - id=2, params=NewSessionRequest(cwd=str(PLAYGROUND_DIR), mcp_servers=[]) + id=2, params=NewSessionRequest(cwd=str(session_cwd), mcp_servers=[]) ), ) session_response = await read_response_for_id(acp_agent_loop_process, expected_id=2) @@ -551,9 +561,9 @@ class TestSessionUpdates: async def start_session_with_request_permission( - process: asyncio.subprocess.Process, prompt: str + process: asyncio.subprocess.Process, prompt: str, cwd: Path = PLAYGROUND_DIR ) -> RequestPermissionJsonRpcRequest: - session_id = await initialize_session(process) + session_id = await initialize_session(process, session_cwd=cwd) await send_json_rpc( process, PromptJsonRpcRequest( @@ -580,7 +590,7 @@ async def start_session_with_request_permission( class TestToolCallStructure: @pytest.mark.asyncio async def test_tool_call_request_permission_structure( - self, vibe_home_grep_ask: Path + self, vibe_home_grep_ask: Path, acp_project_dir: Path ) -> None: custom_results = [ mock_llm_chunk( @@ -600,7 +610,7 @@ class TestToolCallStructure: async for process in get_acp_agent_loop_process( mock_env=mock_env, vibe_home=vibe_home_grep_ask ): - session_id = await initialize_session(process) + session_id = await initialize_session(process, session_cwd=acp_project_dir) await send_json_rpc( process, PromptJsonRpcRequest( @@ -638,8 +648,9 @@ class TestToolCallStructure: @pytest.mark.asyncio async def test_tool_call_update_approved_structure( - self, vibe_home_grep_ask: Path + self, vibe_home_grep_ask: Path, acp_project_dir: Path ) -> None: + (acp_project_dir / "fixture.txt").write_text("auth\n", encoding="utf-8") custom_results = [ mock_llm_chunk( tool_calls=[ @@ -661,7 +672,9 @@ class TestToolCallStructure: mock_env=mock_env, vibe_home=vibe_home_grep_ask ): permission_request = await start_session_with_request_permission( - process, "Search for files containing the pattern 'auth'" + process, + "Search for files containing the pattern 'auth'", + cwd=acp_project_dir, ) assert permission_request.params is not None selected_option_id = ToolOption.ALLOW_ONCE @@ -698,7 +711,7 @@ class TestToolCallStructure: @pytest.mark.asyncio async def test_tool_call_update_rejected_structure( - self, vibe_home_grep_ask: Path + self, vibe_home_grep_ask: Path, acp_project_dir: Path ) -> None: custom_results = [ mock_llm_chunk( @@ -723,7 +736,9 @@ class TestToolCallStructure: mock_env=mock_env, vibe_home=vibe_home_grep_ask ): permission_request = await start_session_with_request_permission( - process, "Search for files containing the pattern 'auth'" + process, + "Search for files containing the pattern 'auth'", + cwd=acp_project_dir, ) assert permission_request.params is not None selected_option_id = ToolOption.REJECT_ONCE @@ -760,7 +775,7 @@ class TestToolCallStructure: @pytest.mark.asyncio async def test_permission_options_include_granular_labels_for_bash( - self, vibe_home_dir: Path + self, vibe_home_dir: Path, acp_project_dir: Path ) -> None: """Bash 'npm install foo' should produce granular labels in permission options.""" custom_results = [ @@ -782,7 +797,7 @@ class TestToolCallStructure: mock_env=mock_env, vibe_home=vibe_home_dir ): permission_request = await start_session_with_request_permission( - process, "Run npm install foo" + process, "Run npm install foo", cwd=acp_project_dir ) assert permission_request.params is not None @@ -799,7 +814,7 @@ 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, vibe_home_grep_ask: Path + self, vibe_home_grep_ask: Path, acp_project_dir: Path ) -> None: custom_results = [ mock_llm_chunk( @@ -821,7 +836,7 @@ class TestToolCallStructure: async for process in get_acp_agent_loop_process( mock_env=mock_env, vibe_home=vibe_home_grep_ask ): - session_id = await initialize_session(process) + session_id = await initialize_session(process, session_cwd=acp_project_dir) await send_json_rpc( process, PromptJsonRpcRequest( @@ -856,7 +871,7 @@ class TestToolCallStructure: @pytest.mark.asyncio async def test_tool_call_result_update_failure_structure( - self, vibe_home_grep_ask: Path + self, vibe_home_grep_ask: Path, acp_project_dir: Path ) -> None: custom_results = [ mock_llm_chunk( @@ -883,6 +898,7 @@ class TestToolCallStructure: permission_request = await start_session_with_request_permission( process, "Search for files containing the pattern 'auth' in /nonexistent", + cwd=acp_project_dir, ) assert permission_request.params is not None selected_option_id = ToolOption.ALLOW_ONCE @@ -934,8 +950,7 @@ class TestCancellationStructure: ToolCall( function=FunctionCall( name="write_file", - arguments='{"path":"test.txt","content":"hello, world!"' - ',"overwrite":false}', + arguments='{"path":"test.txt","content":"hello, world!"}', ), type="function", index=0, diff --git a/tests/acp/test_acp_disabled_tools_merge.py b/tests/acp/test_acp_disabled_tools_merge.py new file mode 100644 index 0000000..2238b13 --- /dev/null +++ b/tests/acp/test_acp_disabled_tools_merge.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from tests.conftest import build_test_vibe_config +from vibe.acp.acp_agent_loop import ( + NON_INTERACTIVE_DISABLED_TOOLS, + _merge_non_interactive_disabled_tools, +) + + +def test_merge_preserves_toml_disabled_tools(): + config = build_test_vibe_config(disabled_tools=["task"]) + + _merge_non_interactive_disabled_tools(config) + + assert "task" in config.disabled_tools + for tool in NON_INTERACTIVE_DISABLED_TOOLS: + assert tool in config.disabled_tools + + +def test_merge_deduplicates(): + config = build_test_vibe_config(disabled_tools=["ask_user_question", "task"]) + + _merge_non_interactive_disabled_tools(config) + + assert config.disabled_tools.count("ask_user_question") == 1 + assert "task" in config.disabled_tools + assert "exit_plan_mode" in config.disabled_tools + + +def test_merge_on_empty_disabled_tools(): + config = build_test_vibe_config() + + _merge_non_interactive_disabled_tools(config) + + assert set(config.disabled_tools) == set(NON_INTERACTIVE_DISABLED_TOOLS) diff --git a/tests/acp/test_search_replace.py b/tests/acp/test_edit.py similarity index 58% rename from tests/acp/test_search_replace.py rename to tests/acp/test_edit.py index 75c40ac..9423125 100644 --- a/tests/acp/test_search_replace.py +++ b/tests/acp/test_edit.py @@ -6,23 +6,12 @@ from acp import ReadTextFileResponse import pytest from tests.mock.utils import collect_result -from vibe.acp.tools.builtins.search_replace import AcpSearchReplaceState, SearchReplace +from vibe.acp.tools.builtins.edit import AcpEditState, Edit from vibe.core.tools.base import ToolError -from vibe.core.tools.builtins.search_replace import ( - SearchReplaceArgs, - SearchReplaceConfig, - SearchReplaceResult, -) +from vibe.core.tools.builtins.edit import EditArgs, EditConfig, EditResult from vibe.core.types import ToolCallEvent, ToolResultEvent -def _make_block(search: str, replace: str) -> str: - head = "<" * 7 + " SEARCH" - sep = "=" * 7 - tail = ">" * 7 + " REPLACE" - return f"{head}\n{search}\n{sep}\n{replace}\n{tail}" - - class MockClient: def __init__( self, @@ -82,52 +71,45 @@ def mock_client() -> MockClient: @pytest.fixture -def acp_search_replace_tool( +def acp_edit_tool( mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> SearchReplace: +) -> Edit: monkeypatch.chdir(tmp_path) - config = SearchReplaceConfig() - state = AcpSearchReplaceState.model_construct( + config = EditConfig() + state = AcpEditState.model_construct( client=mock_client, session_id="test_session_123" ) - return SearchReplace(config_getter=lambda: config, state=state) + return Edit(config_getter=lambda: config, state=state) -class TestAcpSearchReplaceBasic: +class TestAcpEditBasic: def test_get_name(self) -> None: - assert SearchReplace.get_name() == "search_replace" + assert Edit.get_name() == "edit" -class TestAcpSearchReplaceExecution: +class TestAcpEditExecution: @pytest.mark.asyncio async def test_run_success( - self, - acp_search_replace_tool: SearchReplace, - mock_client: MockClient, - tmp_path: Path, + self, acp_edit_tool: Edit, mock_client: MockClient, tmp_path: Path ) -> None: test_file = tmp_path / "test_file.txt" test_file.write_text("original line 1\noriginal line 2\noriginal line 3") - search_replace_content = ( - "<<<<<<< SEARCH\noriginal line 2\n=======\nmodified line 2\n>>>>>>> REPLACE" + args = EditArgs( + file_path=str(test_file), + old_string="original line 2", + new_string="modified line 2", ) - args = SearchReplaceArgs( - file_path=str(test_file), content=search_replace_content - ) - result = await collect_result(acp_search_replace_tool.run(args)) + result = await collect_result(acp_edit_tool.run(args)) - assert isinstance(result, SearchReplaceResult) + assert isinstance(result, EditResult) assert result.file == str(test_file) - assert result.blocks_applied == 1 assert mock_client._read_text_file_called assert mock_client._write_text_file_called - # Verify read_text_file was called correctly read_params = mock_client._last_read_params assert read_params["session_id"] == "test_session_123" assert read_params["path"] == str(test_file) - # Verify write_text_file was called correctly write_params = mock_client._last_write_params assert write_params["session_id"] == "test_session_123" assert write_params["path"] == str(test_file) @@ -136,32 +118,6 @@ class TestAcpSearchReplaceExecution: == "original line 1\nmodified line 2\noriginal line 3" ) - @pytest.mark.asyncio - async def test_run_with_backup( - self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.chdir(tmp_path) - config = SearchReplaceConfig(create_backup=True) - tool = SearchReplace( - config_getter=lambda: config, - state=AcpSearchReplaceState.model_construct( - client=mock_client, session_id="test_session" - ), - ) - - test_file = tmp_path / "test_file.txt" - test_file.write_text("original line 1\noriginal line 2\noriginal line 3") - search_replace_content = ( - "<<<<<<< SEARCH\noriginal line 1\n=======\nmodified line 1\n>>>>>>> REPLACE" - ) - args = SearchReplaceArgs( - file_path=str(test_file), content=search_replace_content - ) - result = await collect_result(tool.run(args)) - - assert result.blocks_applied == 1 - assert sum(w["path"].endswith(".bak") for w in mock_client._write_calls) == 1 - @pytest.mark.asyncio @pytest.mark.parametrize("newline", ["\r\n", "\r", "\n"]) async def test_run_preserves_line_endings( @@ -178,18 +134,19 @@ class TestAcpSearchReplaceExecution: "original line 3", ]) - tool = SearchReplace( - config_getter=lambda: SearchReplaceConfig(), - state=AcpSearchReplaceState.model_construct( + tool = Edit( + config_getter=lambda: EditConfig(), + state=AcpEditState.model_construct( client=mock_client, session_id="test_session" ), ) test_file = tmp_path / "test_file.txt" test_file.touch() - args = SearchReplaceArgs( + args = EditArgs( file_path=str(test_file), - content=_make_block("original line 2", "modified line 2"), + old_string="original line 2", + new_string="modified line 2", ) await collect_result(tool.run(args)) @@ -199,35 +156,6 @@ class TestAcpSearchReplaceExecution: "original line 3", ]) - @pytest.mark.asyncio - async def test_run_backup_preserves_crlf_byte_for_byte( - self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.chdir(tmp_path) - original = "original line 1\r\noriginal line 2\r\noriginal line 3" - mock_client._file_content = original - - tool = SearchReplace( - config_getter=lambda: SearchReplaceConfig(create_backup=True), - state=AcpSearchReplaceState.model_construct( - client=mock_client, session_id="test_session" - ), - ) - - test_file = tmp_path / "test_file.txt" - test_file.touch() - args = SearchReplaceArgs( - file_path=str(test_file), - content=_make_block("original line 1", "modified line 1"), - ) - await collect_result(tool.run(args)) - - backup_calls = [ - w for w in mock_client._write_calls if w["path"].endswith(".bak") - ] - assert len(backup_calls) == 1 - assert backup_calls[0]["content"] == original - @pytest.mark.asyncio async def test_run_read_error( self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -235,26 +163,20 @@ class TestAcpSearchReplaceExecution: monkeypatch.chdir(tmp_path) mock_client._read_error = RuntimeError("File not found") - tool = SearchReplace( - config_getter=lambda: SearchReplaceConfig(), - state=AcpSearchReplaceState.model_construct( + tool = Edit( + config_getter=lambda: EditConfig(), + state=AcpEditState.model_construct( client=mock_client, session_id="test_session" ), ) test_file = tmp_path / "test.txt" test_file.touch() - search_replace_content = "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE" - args = SearchReplaceArgs( - file_path=str(test_file), content=search_replace_content - ) + args = EditArgs(file_path=str(test_file), old_string="old", new_string="new") with pytest.raises(ToolError) as exc_info: await collect_result(tool.run(args)) - assert ( - str(exc_info.value) - == f"Unexpected error reading {test_file}: File not found" - ) + assert str(exc_info.value) == f"Error reading {test_file}: File not found" @pytest.mark.asyncio async def test_run_write_error( @@ -264,19 +186,16 @@ class TestAcpSearchReplaceExecution: mock_client._write_error = RuntimeError("Permission denied") test_file = tmp_path / "test.txt" test_file.touch() - mock_client._file_content = "old" # Update mock to return correct content + mock_client._file_content = "old" - tool = SearchReplace( - config_getter=lambda: SearchReplaceConfig(), - state=AcpSearchReplaceState.model_construct( + tool = Edit( + config_getter=lambda: EditConfig(), + state=AcpEditState.model_construct( client=mock_client, session_id="test_session" ), ) - search_replace_content = "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE" - args = SearchReplaceArgs( - file_path=str(test_file), content=search_replace_content - ) + args = EditArgs(file_path=str(test_file), old_string="old", new_string="new") with pytest.raises(ToolError) as exc_info: await collect_result(tool.run(args)) @@ -309,38 +228,30 @@ class TestAcpSearchReplaceExecution: monkeypatch.chdir(tmp_path) test_file = tmp_path / "test.txt" test_file.touch() - tool = SearchReplace( - config_getter=lambda: SearchReplaceConfig(), - state=AcpSearchReplaceState.model_construct( - client=client, session_id=session_id - ), + tool = Edit( + config_getter=lambda: EditConfig(), + state=AcpEditState.model_construct(client=client, session_id=session_id), ) - search_replace_content = "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE" - args = SearchReplaceArgs( - file_path=str(test_file), content=search_replace_content - ) + args = EditArgs(file_path=str(test_file), old_string="old", new_string="new") with pytest.raises(ToolError) as exc_info: await collect_result(tool.run(args)) assert str(exc_info.value) == expected_error -class TestAcpSearchReplaceSessionUpdates: +class TestAcpEditSessionUpdates: def test_tool_call_session_update(self) -> None: - search_replace_content = ( - "<<<<<<< SEARCH\nold text\n=======\nnew text\n>>>>>>> REPLACE" - ) event = ToolCallEvent( - tool_name="search_replace", + tool_name="edit", tool_call_id="test_call_123", - args=SearchReplaceArgs( - file_path="/tmp/test.txt", content=search_replace_content + args=EditArgs( + file_path="/tmp/test.txt", old_string="old text", new_string="new text" ), - tool_class=SearchReplace, + tool_class=Edit, ) - update = SearchReplace.tool_call_session_update(event) + update = Edit.tool_call_session_update(event) assert update is not None assert update.session_update == "tool_call" assert update.tool_call_id == "test_call_123" @@ -362,40 +273,37 @@ class TestAcpSearchReplaceSessionUpdates: pass event = ToolCallEvent.model_construct( - tool_name="search_replace", + tool_name="edit", tool_call_id="test_call_123", args=InvalidArgs(), # type: ignore[arg-type] - tool_class=SearchReplace, + tool_class=Edit, ) - update = SearchReplace.tool_call_session_update(event) + update = Edit.tool_call_session_update(event) assert update is not None - assert update.title == "search_replace" + assert update.title == "edit" def test_tool_result_session_update(self) -> None: - search_replace_content = ( - "<<<<<<< SEARCH\nold text\n=======\nnew text\n>>>>>>> REPLACE" - ) - result = SearchReplaceResult( + result = EditResult( file="/tmp/test.txt", - blocks_applied=1, - lines_changed=1, - content=search_replace_content, - warnings=[], + message="The file has been updated successfully.", + old_string="old text", + new_string="new text", ) event = ToolResultEvent( - tool_name="search_replace", + tool_name="edit", tool_call_id="test_call_123", result=result, - tool_class=SearchReplace, + tool_class=Edit, ) - update = SearchReplace.tool_result_session_update(event) + update = Edit.tool_result_session_update(event) assert update is not None assert update.session_update == "tool_call_update" assert update.tool_call_id == "test_call_123" assert update.status == "completed" + assert update.kind == "edit" assert update.content is not None assert isinstance(update.content, list) assert len(update.content) == 1 @@ -412,12 +320,12 @@ class TestAcpSearchReplaceSessionUpdates: pass event = ToolResultEvent.model_construct( - tool_name="search_replace", + tool_name="edit", tool_call_id="test_call_123", result=InvalidResult(), # type: ignore[arg-type] - tool_class=SearchReplace, + tool_class=Edit, ) - update = SearchReplace.tool_result_session_update(event) + update = Edit.tool_result_session_update(event) assert update is not None assert update.status == "failed" diff --git a/tests/acp/test_fork_session.py b/tests/acp/test_fork_session.py index 9a29bb9..7b0736c 100644 --- a/tests/acp/test_fork_session.py +++ b/tests/acp/test_fork_session.py @@ -83,7 +83,7 @@ class TestACPForkSession: id="call-1", index=0, function=FunctionCall( - name="read_file", arguments='{"path":"a.txt"}' + name="read", arguments='{"file_path":"a.txt"}' ), ) ], diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index 17ede35..9b379e8 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -62,7 +62,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.13.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.14.0" ) assert response.auth_methods is not None @@ -94,7 +94,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.13.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.14.0" ) assert response.auth_methods is not None diff --git a/tests/acp/test_load_session.py b/tests/acp/test_load_session.py index c79a686..8099c83 100644 --- a/tests/acp/test_load_session.py +++ b/tests/acp/test_load_session.py @@ -238,8 +238,8 @@ class TestLoadSession: "id": "call_123", "type": "function", "function": { - "name": "read_file", - "arguments": '{"path": "/tmp/test.txt"}', + "name": "read", + "arguments": '{"file_path": "/tmp/test.txt"}', }, } ], @@ -254,7 +254,7 @@ class TestLoadSession: u for u in client._session_updates if isinstance(u.update, ToolCallStart) ] assert len(tool_call_starts) == 1 - assert tool_call_starts[0].update.title == "read_file" + assert tool_call_starts[0].update.title == "read" assert tool_call_starts[0].update.tool_call_id == "call_123" tool_results = [ diff --git a/tests/acp/test_read_file.py b/tests/acp/test_read.py similarity index 66% rename from tests/acp/test_read_file.py rename to tests/acp/test_read.py index 1d2bab4..ddcf5fc 100644 --- a/tests/acp/test_read_file.py +++ b/tests/acp/test_read.py @@ -6,12 +6,13 @@ from acp import ReadTextFileResponse import pytest from tests.mock.utils import collect_result -from vibe.acp.tools.builtins.read_file import AcpReadFileState, ReadFile +from vibe.acp.tools.builtins.read import AcpReadState, Read from vibe.core.tools.base import ToolError -from vibe.core.tools.builtins.read_file import ( - ReadFileArgs, - ReadFileResult, - ReadFileToolConfig, +from vibe.core.tools.builtins.read import ( + DEFAULT_LINE_LIMIT, + ReadArgs, + ReadConfig, + ReadResult, ) @@ -49,7 +50,7 @@ class MockClient: content = self._file_content if line is not None or limit is not None: lines = content.splitlines(keepends=True) - start_line = (line or 1) - 1 # Convert to 0-indexed + start_line = (line or 1) - 1 end_line = start_line + limit if limit is not None else len(lines) lines = lines[start_line:end_line] content = "".join(lines) @@ -66,45 +67,43 @@ def mock_client() -> MockClient: @pytest.fixture -def acp_read_file_tool( +def acp_read_tool( mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> ReadFile: +) -> Read: monkeypatch.chdir(tmp_path) - config = ReadFileToolConfig() - state = AcpReadFileState.model_construct( + config = ReadConfig() + state = AcpReadState.model_construct( client=mock_client, # type: ignore[arg-type] session_id="test_session_123", ) - return ReadFile(config_getter=lambda: config, state=state) + return Read(config_getter=lambda: config, state=state) -class TestAcpReadFileBasic: +class TestAcpReadBasic: def test_get_name(self) -> None: - assert ReadFile.get_name() == "read_file" + assert Read.get_name() == "read" -class TestAcpReadFileExecution: +class TestAcpReadExecution: @pytest.mark.asyncio async def test_run_success( - self, acp_read_file_tool: ReadFile, mock_client: MockClient, tmp_path: Path + self, acp_read_tool: Read, mock_client: MockClient, tmp_path: Path ) -> None: test_file = tmp_path / "test_file.txt" test_file.touch() - args = ReadFileArgs(path=str(test_file)) - result = await collect_result(acp_read_file_tool.run(args)) + args = ReadArgs(file_path=str(test_file)) + result = await collect_result(acp_read_tool.run(args)) - assert isinstance(result, ReadFileResult) - assert result.path == str(test_file) - assert result.content == "line 1\nline 2\nline 3" - assert result.lines_read == 3 + assert isinstance(result, ReadResult) + assert result.file_path == str(test_file) + assert result.num_lines == 3 assert mock_client._read_text_file_called - # Verify read_text_file was called correctly params = mock_client._last_read_params assert params["session_id"] == "test_session_123" assert params["path"] == str(test_file) - assert params["line"] is None # offset=0 means no line specified - assert params["limit"] is None + assert params["line"] is None + assert params["limit"] == DEFAULT_LINE_LIMIT + 1 @pytest.mark.asyncio async def test_run_with_offset( @@ -113,21 +112,21 @@ class TestAcpReadFileExecution: monkeypatch.chdir(tmp_path) test_file = tmp_path / "test_file.txt" test_file.touch() - tool = ReadFile( - config_getter=lambda: ReadFileToolConfig(), - state=AcpReadFileState.model_construct( + tool = Read( + config_getter=lambda: ReadConfig(), + state=AcpReadState.model_construct( client=mock_client, session_id="test_session" ), ) - args = ReadFileArgs(path=str(test_file), offset=1) + args = ReadArgs(file_path=str(test_file), offset=2) result = await collect_result(tool.run(args)) - assert result.lines_read == 2 - assert result.content == "line 2\nline 3" + assert result.num_lines == 2 + assert result.start_line == 2 params = mock_client._last_read_params - assert params["line"] == 2 # offset=1 means line 2 (1-indexed) + assert params["line"] == 2 @pytest.mark.asyncio async def test_run_with_limit( @@ -136,21 +135,20 @@ class TestAcpReadFileExecution: monkeypatch.chdir(tmp_path) test_file = tmp_path / "test_file.txt" test_file.touch() - tool = ReadFile( - config_getter=lambda: ReadFileToolConfig(), - state=AcpReadFileState.model_construct( + tool = Read( + config_getter=lambda: ReadConfig(), + state=AcpReadState.model_construct( client=mock_client, session_id="test_session" ), ) - args = ReadFileArgs(path=str(test_file), limit=2) + args = ReadArgs(file_path=str(test_file), limit=2) result = await collect_result(tool.run(args)) - assert result.lines_read == 2 - assert result.content == "line 1\nline 2\n" + assert result.num_lines == 2 params = mock_client._last_read_params - assert params["limit"] == 2 + assert params["limit"] == 3 @pytest.mark.asyncio async def test_run_with_offset_and_limit( @@ -159,22 +157,22 @@ class TestAcpReadFileExecution: monkeypatch.chdir(tmp_path) test_file = tmp_path / "test_file.txt" test_file.touch() - tool = ReadFile( - config_getter=lambda: ReadFileToolConfig(), - state=AcpReadFileState.model_construct( + tool = Read( + config_getter=lambda: ReadConfig(), + state=AcpReadState.model_construct( client=mock_client, session_id="test_session" ), ) - args = ReadFileArgs(path=str(test_file), offset=1, limit=1) + args = ReadArgs(file_path=str(test_file), offset=2, limit=1) result = await collect_result(tool.run(args)) - assert result.lines_read == 1 - assert result.content == "line 2\n" + assert result.num_lines == 1 + assert result.start_line == 2 params = mock_client._last_read_params assert params["line"] == 2 - assert params["limit"] == 1 + assert params["limit"] == 2 @pytest.mark.asyncio async def test_run_read_error( @@ -184,14 +182,14 @@ class TestAcpReadFileExecution: mock_client._read_error = RuntimeError("File not found") test_file = tmp_path / "test.txt" test_file.touch() - tool = ReadFile( - config_getter=lambda: ReadFileToolConfig(), - state=AcpReadFileState.model_construct( + tool = Read( + config_getter=lambda: ReadConfig(), + state=AcpReadState.model_construct( client=mock_client, session_id="test_session" ), ) - args = ReadFileArgs(path=str(test_file)) + args = ReadArgs(file_path=str(test_file)) with pytest.raises(ToolError) as exc_info: await collect_result(tool.run(args)) @@ -204,14 +202,12 @@ class TestAcpReadFileExecution: monkeypatch.chdir(tmp_path) test_file = tmp_path / "test.txt" test_file.touch() - tool = ReadFile( - config_getter=lambda: ReadFileToolConfig(), - state=AcpReadFileState.model_construct( - client=None, session_id="test_session" - ), + tool = Read( + config_getter=lambda: ReadConfig(), + state=AcpReadState.model_construct(client=None, session_id="test_session"), ) - args = ReadFileArgs(path=str(test_file)) + args = ReadArgs(file_path=str(test_file)) with pytest.raises(ToolError) as exc_info: await collect_result(tool.run(args)) @@ -228,12 +224,12 @@ class TestAcpReadFileExecution: test_file = tmp_path / "test.txt" test_file.touch() mock_client = MockClient() - tool = ReadFile( - config_getter=lambda: ReadFileToolConfig(), - state=AcpReadFileState.model_construct(client=mock_client, session_id=None), + tool = Read( + config_getter=lambda: ReadConfig(), + state=AcpReadState.model_construct(client=mock_client, session_id=None), ) - args = ReadFileArgs(path=str(test_file)) + args = ReadArgs(file_path=str(test_file)) with pytest.raises(ToolError) as exc_info: await collect_result(tool.run(args)) diff --git a/tests/acp/test_session_delete.py b/tests/acp/test_session_delete.py new file mode 100644 index 0000000..7f50517 --- /dev/null +++ b/tests/acp/test_session_delete.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +from tests.stubs.fake_client import FakeClient +from vibe.acp.acp_agent_loop import VibeAcpAgentLoop +from vibe.acp.exceptions import InvalidRequestError +from vibe.core.session import last_session_pointer + + +def _write_saved_session( + session_dir: Path, timestamp: str, session_id: str, cwd: str +) -> Path: + saved_session_dir = session_dir / f"session_{timestamp}_{session_id[:8]}" + saved_session_dir.mkdir() + (saved_session_dir / "messages.jsonl").write_text( + json.dumps({"role": "user", "content": "Hello"}) + "\n", encoding="utf-8" + ) + (saved_session_dir / "meta.json").write_text( + json.dumps({ + "session_id": session_id, + "start_time": "2024-01-01T12:00:00Z", + "end_time": "2024-01-01T12:05:00Z", + "git_commit": None, + "git_branch": None, + "username": "test-user", + "environment": {"working_directory": cwd}, + "title": "Saved session", + }), + encoding="utf-8", + ) + return saved_session_dir + + +class TestSessionDelete: + @pytest.mark.asyncio + async def test_deletes_saved_but_not_loaded_session( + self, + acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], + temp_session_dir: Path, + create_test_session, + ) -> None: + acp_agent = acp_agent_with_session_config[0] + session_id = "offline-session-12345678" + session_dir = create_test_session(temp_session_dir, session_id, str(Path.cwd())) + + result = await acp_agent.ext_method("session/delete", {"sessionId": session_id}) + + assert result == {} + assert not session_dir.exists() + response = await acp_agent.list_sessions() + assert response.sessions == [] + + @pytest.mark.asyncio + async def test_deletes_saved_session_and_clears_last_session_pointer( + self, + acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], + temp_session_dir: Path, + create_test_session, + ) -> None: + acp_agent = acp_agent_with_session_config[0] + session_id = "pointer-session-12345678" + session_dir = create_test_session(temp_session_dir, session_id, str(Path.cwd())) + pointer_dir = temp_session_dir / last_session_pointer.POINTER_DIR_NAME + pointer_dir.mkdir() + matching_pointer = pointer_dir / "ttys001" + other_pointer = pointer_dir / "ttys002" + matching_pointer.write_text(f"{session_id}\n", encoding="utf-8") + other_pointer.write_text("other-session\n", encoding="utf-8") + + result = await acp_agent.ext_method("session/delete", {"sessionId": session_id}) + + assert result == {} + assert not session_dir.exists() + assert not matching_pointer.exists() + assert other_pointer.read_text(encoding="utf-8") == "other-session\n" + + @pytest.mark.asyncio + async def test_deletes_loaded_saved_session( + self, + acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], + temp_session_dir: Path, + create_test_session, + ) -> None: + acp_agent = acp_agent_with_session_config[0] + saved_session_id = "saved-session-12345678" + acp_session_id = saved_session_id[:8] + cwd = str(Path.cwd()) + session_dir = create_test_session(temp_session_dir, saved_session_id, cwd) + + await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=acp_session_id) + session = acp_agent.sessions[acp_session_id] + session.agent_loop.telemetry_client.aclose = AsyncMock() + + result = await acp_agent.ext_method( + "session/delete", {"sessionId": saved_session_id} + ) + + assert result == {} + assert acp_session_id not in acp_agent.sessions + assert not session_dir.exists() + response = await acp_agent.list_sessions() + assert response.sessions == [] + + @pytest.mark.asyncio + async def test_deletes_live_unsaved_session_without_saved_history( + self, acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient] + ) -> None: + acp_agent = acp_agent_with_session_config[0] + response = await acp_agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + assert response is not None + session = acp_agent.sessions[response.session_id] + session.agent_loop.telemetry_client.aclose = AsyncMock() + assert not session.agent_loop.session_logger.metadata_filepath.exists() + + result = await acp_agent.ext_method( + "session/delete", {"sessionId": response.session_id} + ) + + assert result == {} + assert response.session_id not in acp_agent.sessions + + @pytest.mark.asyncio + async def test_raises_on_invalid_params( + self, acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient] + ) -> None: + acp_agent = acp_agent_with_session_config[0] + + with pytest.raises(InvalidRequestError): + await acp_agent.ext_method("session/delete", {}) + + with pytest.raises(InvalidRequestError): + await acp_agent.ext_method("session/delete", {"sessionId": " "}) + + with pytest.raises(InvalidRequestError): + await acp_agent.ext_method( + "session/delete", {"savedSessionId": "unsupported-session"} + ) + + @pytest.mark.asyncio + async def test_succeeds_when_session_cannot_be_found( + self, acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient] + ) -> None: + acp_agent = acp_agent_with_session_config[0] + + result = await acp_agent.ext_method( + "session/delete", {"sessionId": "missing-session"} + ) + + assert result == {} + + @pytest.mark.asyncio + async def test_requires_exact_saved_session_id_before_deleting( + self, + acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], + temp_session_dir: Path, + ) -> None: + acp_agent = acp_agent_with_session_config[0] + cwd = str(Path.cwd()) + collision_dir = _write_saved_session( + temp_session_dir, "20240101_120000", "aaaaaaaa-1111", cwd + ) + target_dir = _write_saved_session( + temp_session_dir, "20240101_120500", "aaaaaaaa-2222", cwd + ) + + result = await acp_agent.ext_method( + "session/delete", {"sessionId": "aaaaaaaa-2222"} + ) + + assert result == {} + assert collision_dir.exists() + assert not target_dir.exists() diff --git a/tests/acp/test_todo.py b/tests/acp/test_todo.py new file mode 100644 index 0000000..9b8bee5 --- /dev/null +++ b/tests/acp/test_todo.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from vibe.acp.tools.builtins.todo import Todo +from vibe.core.tools.builtins.todo import TodoItem, TodoPriority, TodoResult, TodoStatus +from vibe.core.types import ToolResultEvent + + +class TestAcpTodoSessionUpdates: + def test_tool_result_session_update(self) -> None: + result = TodoResult( + message="Updated 2 todos", + todos=[ + TodoItem( + id="1", + content="First", + status=TodoStatus.IN_PROGRESS, + priority=TodoPriority.HIGH, + ), + TodoItem(id="2", content="Second", status=TodoStatus.PENDING), + ], + total_count=2, + ) + + event = ToolResultEvent( + tool_name="todo", + tool_call_id="test_call_123", + result=result, + tool_class=Todo, + ) + + update = Todo.tool_result_session_update(event) + assert update is not None + assert update.session_update == "plan" + assert len(update.entries) == 2 + assert update.entries[0].content == "First" + assert update.entries[0].status == "in_progress" + assert update.entries[0].priority == "high" + + def test_tool_result_session_update_failed_result(self) -> None: + event = ToolResultEvent( + tool_name="todo", + tool_call_id="test_call_123", + error="Todo IDs must be unique", + tool_class=Todo, + ) + + update = Todo.tool_result_session_update(event) + assert update is not None + assert update.status == "failed" + + def test_tool_result_session_update_invalid_result(self) -> None: + class InvalidResult: + pass + + event = ToolResultEvent.model_construct( + tool_name="todo", + tool_call_id="test_call_123", + result=InvalidResult(), # type: ignore[arg-type] + tool_class=Todo, + ) + + update = Todo.tool_result_session_update(event) + assert update is not None + assert update.status == "failed" diff --git a/tests/acp/test_tool_call_session_update.py b/tests/acp/test_tool_call_session_update.py index ef92a8c..5211e73 100644 --- a/tests/acp/test_tool_call_session_update.py +++ b/tests/acp/test_tool_call_session_update.py @@ -2,19 +2,19 @@ from __future__ import annotations from acp.schema import ToolCallStart -from vibe.acp.tools.builtins.read_file import ReadFile +from vibe.acp.tools.builtins.read import Read from vibe.acp.tools.session_update import tool_call_session_update -from vibe.core.tools.builtins.read_file import ReadFileArgs +from vibe.core.tools.builtins.read import ReadArgs from vibe.core.types import ToolCallEvent class TestToolCallSessionUpdate: def _create_event(self) -> ToolCallEvent: return ToolCallEvent( - tool_name="read_file", + tool_name="read", tool_call_id="test_call_123", - args=ReadFileArgs(path="/tmp/test.txt"), - tool_class=ReadFile, + args=ReadArgs(file_path="/tmp/test.txt"), + tool_class=Read, ) def test_returns_tool_call_start(self) -> None: @@ -29,10 +29,7 @@ class TestToolCallSessionUpdate: def test_returns_tool_call_start_for_streaming_event(self) -> None: event = ToolCallEvent( - tool_name="read_file", - tool_call_id="test_call_123", - tool_class=ReadFile, - args=None, + tool_name="read", tool_call_id="test_call_123", tool_class=Read, args=None ) update = tool_call_session_update(event) diff --git a/tests/acp/test_tool_field_meta.py b/tests/acp/test_tool_field_meta.py index 922961c..4ccd673 100644 --- a/tests/acp/test_tool_field_meta.py +++ b/tests/acp/test_tool_field_meta.py @@ -8,7 +8,7 @@ from acp.schema import ToolCallProgress, ToolCallStart from pydantic import BaseModel from vibe.acp.tools.builtins.grep import Grep -from vibe.acp.tools.builtins.read_file import ReadFile +from vibe.acp.tools.builtins.read import Read from vibe.acp.tools.builtins.skill import Skill from vibe.acp.tools.builtins.task import Task from vibe.acp.tools.builtins.web_fetch import WebFetch @@ -19,7 +19,7 @@ from vibe.acp.tools.session_update import ( tool_result_session_update, ) from vibe.core.tools.builtins.grep import GrepArgs, GrepResult -from vibe.core.tools.builtins.read_file import ReadFileArgs, ReadFileResult +from vibe.core.tools.builtins.read import ReadArgs, ReadResult from vibe.core.tools.builtins.skill import SkillArgs, SkillResult from vibe.core.tools.builtins.task import TaskArgs, TaskResult from vibe.core.tools.builtins.webfetch import WebFetchArgs, WebFetchResult @@ -99,10 +99,10 @@ class TestGrepFieldMeta: assert update.field_meta == {"tool_name": "grep"} -class TestReadFileFieldMeta: +class TestReadFieldMeta: def test_call_location_has_offset_and_limit(self) -> None: event = _call_event( - "read_file", ReadFile, ReadFileArgs(path="/tmp/f.txt", offset=10, limit=50) + "read", Read, ReadArgs(file_path="/tmp/f.txt", offset=10, limit=50) ) update = tool_call_session_update(event) @@ -110,26 +110,26 @@ class TestReadFileFieldMeta: assert update.locations is not None loc = update.locations[0] assert loc.field_meta == {"type": "file_range", "offset": 10, "limit": 50} - assert update.field_meta == {"tool_name": "read_file"} + assert update.field_meta == {"tool_name": "read"} - def test_call_defaults_offset_zero_limit_none(self) -> None: - event = _call_event("read_file", ReadFile, ReadFileArgs(path="/tmp/f.txt")) + def test_call_defaults_offset_none_limit_default(self) -> None: + event = _call_event("read", Read, ReadArgs(file_path="/tmp/f.txt")) update = tool_call_session_update(event) assert isinstance(update, ToolCallStart) assert update.locations is not None loc = update.locations[0] - assert loc.field_meta == {"type": "file_range", "offset": 0, "limit": None} + assert loc.field_meta == {"type": "file_range", "offset": None, "limit": 2000} - def test_result_location_has_offset_and_lines_read(self) -> None: - result = ReadFileResult( - path="/tmp/f.txt", - content="line1\nline2\nline3\n", - lines_read=3, - was_truncated=False, - offset=10, + def test_result_location_has_start_line_and_num_lines(self) -> None: + result = ReadResult( + file_path="/tmp/f.txt", + content=" 1→line1\n 2→line2\n 3→line3", + num_lines=3, + start_line=10, + total_lines=20, ) - event = _result_event("read_file", ReadFile, result) + event = _result_event("read", Read, result) update = tool_result_session_update(event) assert isinstance(update, ToolCallProgress) @@ -287,9 +287,7 @@ class TestWriteFileFieldMeta: assert update.locations[0].path == str(Path("out.txt").resolve()) def test_result_location_is_resolved_path(self) -> None: - result = WriteFileResult( - path="out.txt", content="hello", bytes_written=5, file_existed=False - ) + result = WriteFileResult(path="out.txt", content="hello", bytes_written=5) event = _result_event("write_file", WriteFile, result) update = tool_result_session_update(event) diff --git a/tests/acp/test_write_file.py b/tests/acp/test_write_file.py index be1ec43..3b5d865 100644 --- a/tests/acp/test_write_file.py +++ b/tests/acp/test_write_file.py @@ -77,7 +77,6 @@ class TestAcpWriteFileExecution: assert result.path == str(test_file) assert result.content == "Hello, world!" assert result.bytes_written == len(b"Hello, world!") - assert result.file_existed is False assert mock_client._write_text_file_called # Verify write_text_file was called correctly @@ -87,7 +86,7 @@ class TestAcpWriteFileExecution: assert params["content"] == "Hello, world!" @pytest.mark.asyncio - async def test_run_success_overwrite( + async def test_run_existing_file_raises( self, mock_client: MockClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) @@ -100,23 +99,11 @@ class TestAcpWriteFileExecution: test_file = tmp_path / "existing_file.txt" test_file.touch() - # Simulate existing file by checking in the core tool logic - # The ACP tool doesn't check existence, it's handled by the core tool - args = WriteFileArgs(path=str(test_file), content="New content", overwrite=True) - result = await collect_result(tool.run(args)) + args = WriteFileArgs(path=str(test_file), content="New content") + with pytest.raises(ToolError, match="already exists"): + await collect_result(tool.run(args)) - assert isinstance(result, WriteFileResult) - assert result.path == str(test_file) - assert result.content == "New content" - assert result.bytes_written == len(b"New content") - assert result.file_existed is True - assert mock_client._write_text_file_called - - # Verify write_text_file was called correctly - params = mock_client._last_write_params - assert params["session_id"] == "test_session" - assert params["path"] == str(test_file) - assert params["content"] == "New content" + assert not mock_client._write_text_file_called @pytest.mark.asyncio @pytest.mark.parametrize("input_newline", ["\r\n", "\r", "\n"]) @@ -305,9 +292,7 @@ class TestAcpWriteFileSessionUpdates: assert update.title == "write_file" def test_tool_result_session_update(self) -> None: - result = WriteFileResult( - path="/tmp/test.txt", content="Hello", bytes_written=5, file_existed=False - ) + result = WriteFileResult(path="/tmp/test.txt", content="Hello", bytes_written=5) event = ToolResultEvent( tool_name="write_file", diff --git a/tests/autocompletion/test_path_prompt.py b/tests/autocompletion/test_path_prompt.py index 18a5967..2103da6 100644 --- a/tests/autocompletion/test_path_prompt.py +++ b/tests/autocompletion/test_path_prompt.py @@ -2,6 +2,8 @@ from __future__ import annotations from pathlib import Path +import pytest + from vibe.core.autocompletion.path_prompt import ( build_path_prompt_payload, build_title_segments, @@ -22,6 +24,47 @@ def test_deduplicates_same_file_mentioned_twice(tmp_path: Path) -> None: assert len(payload.all_resources) == 2 +class TestTildeExpansion: + def test_tilde_path_expands_and_attaches( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + shot = tmp_path / "shot.png" + shot.write_bytes(b"\x89PNG") + + payload = build_path_prompt_payload("look at @~/shot.png", base_dir=tmp_path) + + assert len(payload.resources) == 1 + resource = payload.resources[0] + assert resource.path == shot.resolve() + assert resource.alias == "~/shot.png" + assert resource.kind == "image" + + def test_bare_tilde_attaches_home_as_folder( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + + payload = build_path_prompt_payload("go to @~", base_dir=tmp_path) + + assert len(payload.resources) == 1 + assert payload.resources[0].path == tmp_path.resolve() + assert payload.resources[0].kind == "folder" + + def test_unknown_user_tilde_does_not_crash(self, tmp_path: Path) -> None: + payload = build_path_prompt_payload( + "see @~nonexistentuser1234/x.png", base_dir=tmp_path + ) + + assert payload.resources == [] + + def test_tilde_mid_token_without_anchor_stays_text(self, tmp_path: Path) -> None: + payload = build_path_prompt_payload("foo~bar baz", base_dir=tmp_path) + + assert payload.resources == [] + assert payload.prompt_text == "foo~bar baz" + + class TestBuildTitleSegments: def test_empty_message(self) -> None: assert build_title_segments("") == [] diff --git a/tests/autocompletion/test_path_prompt_image.py b/tests/autocompletion/test_path_prompt_image.py new file mode 100644 index 0000000..6ef1fb4 --- /dev/null +++ b/tests/autocompletion/test_path_prompt_image.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from pathlib import Path + +from vibe.core.autocompletion.path_prompt import build_path_prompt_payload +from vibe.core.autocompletion.path_prompt_adapter import ( + extract_image_resources, + render_path_prompt, +) + + +def test_image_extension_is_classified_as_image_kind(tmp_path: Path) -> None: + (tmp_path / "logo.png").write_bytes(b"\x89PNG") + + payload = build_path_prompt_payload("look at @logo.png", base_dir=tmp_path) + + assert len(payload.resources) == 1 + assert payload.resources[0].kind == "image" + assert payload.resources[0].alias == "logo.png" + + +def test_text_file_remains_kind_file(tmp_path: Path) -> None: + (tmp_path / "notes.md").write_text("hello") + + payload = build_path_prompt_payload("read @notes.md", base_dir=tmp_path) + + assert payload.resources[0].kind == "file" + + +def test_image_mentions_omit_resource_link_when_skip_images(tmp_path: Path) -> None: + (tmp_path / "logo.png").write_bytes(b"\x89PNG") + + rendered = render_path_prompt( + "look at @logo.png", base_dir=tmp_path, skip_images=True + ) + + assert "logo.png" in rendered # kept in the prompt text as the @ mention + assert "file://" not in rendered # no resource_link block emitted + + +def test_image_mentions_emit_resource_link_by_default(tmp_path: Path) -> None: + (tmp_path / "logo.png").write_bytes(b"\x89PNG") + + rendered = render_path_prompt("look at @logo.png", base_dir=tmp_path) + + assert "logo.png" in rendered + assert (tmp_path / "logo.png").as_uri() in rendered + + +def test_extract_image_resources_filters_only_images(tmp_path: Path) -> None: + (tmp_path / "logo.png").write_bytes(b"\x89PNG") + (tmp_path / "notes.md").write_text("hi") + (tmp_path / "diagram.webp").write_bytes(b"RIFF") + + payload = build_path_prompt_payload( + "see @logo.png @notes.md @diagram.webp", base_dir=tmp_path + ) + + images = extract_image_resources(payload) + + aliases = sorted(r.alias for r in images) + assert aliases == ["diagram.webp", "logo.png"] + + +def test_case_insensitive_image_extension(tmp_path: Path) -> None: + (tmp_path / "screenshot.PNG").write_bytes(b"\x89PNG") + + payload = build_path_prompt_payload("see @screenshot.PNG", base_dir=tmp_path) + + assert payload.resources[0].kind == "image" diff --git a/tests/backend/test_image_encoding_cache.py b/tests/backend/test_image_encoding_cache.py new file mode 100644 index 0000000..d54bad6 --- /dev/null +++ b/tests/backend/test_image_encoding_cache.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import base64 +from pathlib import Path + +import pytest + +from vibe.core.llm.backend._image import ( + ImageReadError, + _encode_cached, + to_base64, + to_data_uri, +) +from vibe.core.types import ImageAttachment + +PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 + + +@pytest.fixture(autouse=True) +def _clear_cache() -> None: + _encode_cached.cache_clear() + + +def _att(tmp_path: Path, name: str = "shot.png") -> ImageAttachment: + p = tmp_path / name + p.write_bytes(PNG_BYTES) + return ImageAttachment(path=p, alias=name, mime_type="image/png") + + +def test_repeated_calls_hit_cache_and_skip_disk(tmp_path: Path) -> None: + att = _att(tmp_path) + + first = to_base64(att) + second = to_base64(att) + third = to_data_uri(att) + + assert first == second == base64.b64encode(PNG_BYTES).decode("ascii") + assert third == f"data:image/png;base64,{first}" + info = _encode_cached.cache_info() + assert info.misses == 1 + assert info.hits == 2 + + +def test_cache_invalidates_when_file_mtime_changes(tmp_path: Path) -> None: + att = _att(tmp_path) + to_base64(att) + assert _encode_cached.cache_info().misses == 1 + + new_bytes = PNG_BYTES + b"\x01" + att.path.write_bytes(new_bytes) + # Force a distinct mtime even on coarse-resolution filesystems. + import os + + stat = att.path.stat() + os.utime(att.path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000)) + + refreshed = to_base64(att) + assert refreshed == base64.b64encode(new_bytes).decode("ascii") + assert _encode_cached.cache_info().misses == 2 + + +def test_missing_file_raises_image_read_error(tmp_path: Path) -> None: + att = ImageAttachment( + path=tmp_path / "nope.png", alias="nope.png", mime_type="image/png" + ) + + with pytest.raises(ImageReadError): + to_data_uri(att) diff --git a/tests/backend/test_image_mapping.py b/tests/backend/test_image_mapping.py new file mode 100644 index 0000000..d9aafe3 --- /dev/null +++ b/tests/backend/test_image_mapping.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import base64 +from collections.abc import Sequence +import json +from pathlib import Path +from typing import Any + +import pytest + +from vibe.core.llm.backend.anthropic import AnthropicAdapter +from vibe.core.llm.backend.base import APIAdapter +from vibe.core.llm.backend.generic import OpenAIAdapter +from vibe.core.llm.backend.mistral import MistralMapper +from vibe.core.llm.backend.openai_responses import OpenAIResponsesAdapter +from vibe.core.llm.backend.reasoning_adapter import ReasoningAdapter +from vibe.core.types import ImageAttachment, LLMMessage, Role + +PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 +EXPECTED_B64 = base64.b64encode(PNG_BYTES).decode("ascii") +EXPECTED_DATA_URI = f"data:image/png;base64,{EXPECTED_B64}" + + +@pytest.fixture() +def image_attachment(tmp_path: Path) -> ImageAttachment: + path = tmp_path / "shot.png" + path.write_bytes(PNG_BYTES) + return ImageAttachment(path=path, alias="shot.png", mime_type="image/png") + + +def _user_message(image: ImageAttachment) -> LLMMessage: + return LLMMessage(role=Role.user, content="describe this", images=[image]) + + +class _FakeProvider: + name = "mistral" + reasoning_field_name = "reasoning_content" + + +def _adapter_payload( + adapter: APIAdapter, + messages: Sequence[LLMMessage], + *, + model: str = "gpt-4o", + **overrides: Any, +) -> dict[str, Any]: + kwargs: dict[str, Any] = { + "model_name": model, + "messages": list(messages), + "temperature": 0.0, + "tools": None, + "max_tokens": None, + "tool_choice": None, + "enable_streaming": False, + "provider": _FakeProvider(), + "api_key": "k", + } + kwargs.update(overrides) + prepared = adapter.prepare_request(**kwargs) + return json.loads(prepared.body.decode("utf-8")) + + +def test_mistral_mapper_emits_image_url_chunk( + image_attachment: ImageAttachment, +) -> None: + mapper = MistralMapper() + prepared = mapper.prepare_message(_user_message(image_attachment)) + + dumped = prepared.model_dump() + assert dumped["role"] == "user" + parts = dumped["content"] + assert parts[0] == {"type": "text", "text": "describe this"} + assert parts[1]["type"] == "image_url" + assert parts[1]["image_url"]["url"] == EXPECTED_DATA_URI + + +def test_openai_adapter_emits_image_url_part(image_attachment: ImageAttachment) -> None: + payload = _adapter_payload(OpenAIAdapter(), [_user_message(image_attachment)]) + msg = payload["messages"][0] + assert msg["role"] == "user" + assert msg["content"][0] == {"type": "text", "text": "describe this"} + assert msg["content"][1] == { + "type": "image_url", + "image_url": {"url": EXPECTED_DATA_URI}, + } + assert "images" not in msg + + +def test_openai_responses_adapter_emits_input_image_part( + image_attachment: ImageAttachment, +) -> None: + payload = _adapter_payload( + OpenAIResponsesAdapter(), [_user_message(image_attachment)] + ) + user = payload["input"][0] + assert user["role"] == "user" + assert user["content"][0] == {"type": "input_text", "text": "describe this"} + assert user["content"][1] == {"type": "input_image", "image_url": EXPECTED_DATA_URI} + + +def test_anthropic_adapter_emits_image_source( + image_attachment: ImageAttachment, +) -> None: + payload = _adapter_payload( + AnthropicAdapter(), [_user_message(image_attachment)], model="claude-3-5-sonnet" + ) + msg = payload["messages"][0] + assert msg["role"] == "user" + assert msg["content"][0] == {"type": "text", "text": "describe this"} + image_block = msg["content"][1] + assert image_block["type"] == "image" + assert image_block["source"] == { + "type": "base64", + "media_type": "image/png", + "data": EXPECTED_B64, + } + # prepare_request stamps cache_control on the last block of the last user turn + assert image_block["cache_control"] == {"type": "ephemeral"} + + +def test_reasoning_adapter_emits_image_url_part( + image_attachment: ImageAttachment, +) -> None: + payload = _adapter_payload(ReasoningAdapter(), [_user_message(image_attachment)]) + msg = payload["messages"][0] + assert msg["role"] == "user" + assert msg["content"][0] == {"type": "text", "text": "describe this"} + assert msg["content"][1] == { + "type": "image_url", + "image_url": {"url": EXPECTED_DATA_URI}, + } + + +def test_text_only_user_message_keeps_string_content() -> None: + text_msg = LLMMessage(role=Role.user, content="hi") + + mistral_prepared = MistralMapper().prepare_message(text_msg) + assert mistral_prepared.model_dump()["content"] == "hi" + + anthropic_payload = _adapter_payload( + AnthropicAdapter(), [text_msg], model="claude-3-5-sonnet" + ) + # Anthropic emits parts even for text-only; keep as wrapped text part + text_block = anthropic_payload["messages"][0]["content"][0] + assert text_block["type"] == "text" + assert text_block["text"] == "hi" diff --git a/tests/backend/test_ssl_integration.py b/tests/backend/test_ssl_integration.py index 045c7b1..3de7d3e 100644 --- a/tests/backend/test_ssl_integration.py +++ b/tests/backend/test_ssl_integration.py @@ -91,7 +91,8 @@ def _write_tls_material(tmp_path: Path) -> _TLSMaterial: ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Vibe test CA")]) ca_cert = ( - x509.CertificateBuilder() + x509 + .CertificateBuilder() .subject_name(ca_name) .issuer_name(ca_name) .public_key(ca_key.public_key()) @@ -105,7 +106,8 @@ def _write_tls_material(tmp_path: Path) -> _TLSMaterial: server_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) server_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "127.0.0.1")]) server_cert = ( - x509.CertificateBuilder() + x509 + .CertificateBuilder() .subject_name(server_name) .issuer_name(ca_cert.subject) .public_key(server_key.public_key()) diff --git a/tests/banner/test_banner_initial_state.py b/tests/banner/test_banner_initial_state.py index 6a8797c..ca48369 100644 --- a/tests/banner/test_banner_initial_state.py +++ b/tests/banner/test_banner_initial_state.py @@ -44,7 +44,7 @@ class TestBannerInitialState: banner = Banner( config=_make_mock_config(), skill_manager=skill_manager, - connectors_enabled=5, + connectors_connected=5, connectors_total=5, ) @@ -54,7 +54,7 @@ class TestBannerInitialState: banner._initial_state.mcp_servers_enabled == 0 ) # No MCP servers configured assert banner._initial_state.mcp_servers_total == 0 - assert banner._initial_state.connectors_enabled == 5 + assert banner._initial_state.connectors_connected == 5 assert banner._initial_state.connectors_total == 5 assert banner._initial_state.skills_count == 0 @@ -64,7 +64,7 @@ class TestBannerInitialState: banner = Banner(config=_make_mock_config(), skill_manager=skill_manager) - assert banner._initial_state.connectors_enabled == 0 + assert banner._initial_state.connectors_connected == 0 assert banner._initial_state.connectors_total == 0 def test_banner_shows_thinking_level(self) -> None: @@ -88,7 +88,7 @@ class TestBannerInitialState: models_count=2, mcp_servers_enabled=1, mcp_servers_total=2, - connectors_enabled=3, + connectors_connected=3, connectors_total=3, skills_count=5, ) @@ -103,7 +103,7 @@ class TestBannerInitialState: models_count=2, mcp_servers_enabled=1, mcp_servers_total=2, - connectors_enabled=0, + connectors_connected=0, connectors_total=0, skills_count=5, ) @@ -222,9 +222,9 @@ class TestBannerConnectorsCount: banner = Banner( config=_make_mock_config(), skill_manager=skill_manager, - connectors_enabled=3, + connectors_connected=3, connectors_total=5, ) - assert banner._initial_state.connectors_enabled == 3 + assert banner._initial_state.connectors_connected == 3 assert banner._initial_state.connectors_total == 5 diff --git a/tests/browser_sign_in/test_browser_sign_in.py b/tests/browser_sign_in/test_browser_sign_in.py index 01a6ca9..a0f7409 100644 --- a/tests/browser_sign_in/test_browser_sign_in.py +++ b/tests/browser_sign_in/test_browser_sign_in.py @@ -18,12 +18,15 @@ from tests.browser_sign_in.stubs import ( noop_sleep, ) from vibe.setup.auth import ( + BrowserSignInAttemptStarted, BrowserSignInError, BrowserSignInErrorCode, + BrowserSignInEvent, BrowserSignInPollResult, BrowserSignInProcess, BrowserSignInService, BrowserSignInStatus, + BrowserSignInStatusChanged, ) TEST_NOW = datetime(2026, 3, 16, tzinfo=UTC) @@ -69,7 +72,7 @@ def build_test_service( @pytest.mark.asyncio async def test_authenticate_returns_api_key_after_pending_poll() -> None: opened_urls: list[str] = [] - statuses: list[BrowserSignInStatus] = [] + events: list[BrowserSignInEvent] = [] gateway, service = build_test_service( poll_results=[ BrowserSignInPollResult(status="pending"), @@ -78,17 +81,23 @@ async def test_authenticate_returns_api_key_after_pending_poll() -> None: open_browser=lambda url: opened_urls.append(url) or True, ) - api_key = await service.authenticate(status_callback=statuses.append) + api_key = await service.authenticate(event_callback=events.append) code_verifier = gateway.exchange_requests[0].code_verifier assert gateway.code_challenges == [build_code_challenge(code_verifier)] assert api_key == "sk-browser-key" assert opened_urls == [TEST_SIGN_IN_URL] - assert statuses == [ - BrowserSignInStatus.OPENING_BROWSER, - BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN, - BrowserSignInStatus.EXCHANGING, - BrowserSignInStatus.COMPLETED, + assert events == [ + BrowserSignInAttemptStarted( + sign_in_url=TEST_SIGN_IN_URL, + expires_at=build_sign_in_process(TEST_NOW).expires_at, + ), + BrowserSignInStatusChanged(status=BrowserSignInStatus.OPENING_BROWSER), + BrowserSignInStatusChanged( + status=BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN + ), + BrowserSignInStatusChanged(status=BrowserSignInStatus.EXCHANGING), + BrowserSignInStatusChanged(status=BrowserSignInStatus.COMPLETED), ] assert gateway.polled_urls == [TEST_POLL_URL, TEST_POLL_URL] assert gateway.exchange_requests[0].exchange_token == "exchange-1" @@ -115,7 +124,6 @@ async def test_start_attempt_returns_attempt_without_opening_browser() -> None: @pytest.mark.asyncio async def test_complete_attempt_returns_api_key_without_opening_browser() -> None: opened_urls: list[str] = [] - statuses: list[BrowserSignInStatus] = [] gateway, service = build_test_service( poll_results=[ BrowserSignInPollResult(status="pending"), @@ -125,15 +133,10 @@ async def test_complete_attempt_returns_api_key_without_opening_browser() -> Non ) attempt = await service.start_attempt() - api_key = await service.complete_attempt(attempt, status_callback=statuses.append) + api_key = await service.complete_attempt(attempt) assert api_key == "sk-browser-key" assert opened_urls == [] - assert statuses == [ - BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN, - BrowserSignInStatus.EXCHANGING, - BrowserSignInStatus.COMPLETED, - ] assert gateway.polled_urls == [TEST_POLL_URL, TEST_POLL_URL] assert gateway.exchange_requests[0].exchange_token == "exchange-1" @@ -241,10 +244,19 @@ async def test_authenticate_raises_on_unknown_poll_state() -> None: @pytest.mark.asyncio async def test_authenticate_raises_when_browser_cannot_be_opened() -> None: + events: list[BrowserSignInEvent] = [] _, service = build_test_service(poll_results=[], open_browser=lambda _: False) with pytest.raises(BrowserSignInError, match="open browser"): - await service.authenticate() + await service.authenticate(event_callback=events.append) + + assert events == [ + BrowserSignInAttemptStarted( + sign_in_url=TEST_SIGN_IN_URL, + expires_at=build_sign_in_process(TEST_NOW).expires_at, + ), + BrowserSignInStatusChanged(status=BrowserSignInStatus.OPENING_BROWSER), + ] @pytest.mark.asyncio diff --git a/tests/cli/plan_offer/test_decide_plan_offer.py b/tests/cli/plan_offer/test_decide_plan_offer.py index 66bdb54..7278907 100644 --- a/tests/cli/plan_offer/test_decide_plan_offer.py +++ b/tests/cli/plan_offer/test_decide_plan_offer.py @@ -12,6 +12,7 @@ from vibe.cli.plan_offer.decide_plan_offer import ( WhoAmIPlanType, decide_plan_offer, plan_offer_cta, + plan_title, resolve_api_key_for_plan, ) from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse @@ -263,3 +264,20 @@ def test_teleport_eligibility_depends_on_chat_plan_and_current_key( response: WhoAmIResponse, expected: bool ) -> None: assert PlanInfo.from_response(response).is_teleport_eligible() is expected + + +@pytest.mark.parametrize( + ("payload", "expected_title"), + [ + (PlanInfo(plan_type=WhoAmIPlanType.API, plan_name="FREE"), "Free"), + ( + PlanInfo(plan_type=WhoAmIPlanType.API, plan_name="Scale plan"), + "[API] Scale plan", + ), + ], + ids=["free-api-plan", "paid-api-plan"], +) +def test_plan_title_uses_current_api_plan_labels( + payload: PlanInfo, expected_title: str +) -> None: + assert plan_title(payload) == expected_title diff --git a/tests/cli/smoke_binary.py b/tests/cli/smoke_binary.py new file mode 100644 index 0000000..4e8ed97 --- /dev/null +++ b/tests/cli/smoke_binary.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +"""Smoke tests for the built vibe binary. + +Usage: python tests/cli/smoke_binary.py + +Tests: + 1. --version exits successfully + 2. Normal interactive launch starts far enough to load the main Textual app + 3. --setup starts far enough to load bundled setup/Textual assets + 4. Programmatic mode without an API key fails with the expected config error + 5. Runtime data files are present in the bundle + 6. The relocated bundle can be launched from PATH + 7. (Linux) No ELF binaries require executable stack (GNU_STACK RWE) +""" + +from __future__ import annotations + +import os +from pathlib import Path +import platform +import shutil +import struct +import subprocess +import sys +import tempfile +import time +from typing import NoReturn + + +def _fail(msg: str) -> NoReturn: + print(f"FAIL: {msg}", file=sys.stderr) + sys.exit(1) + + +def _isolated_env(vibe_home: Path) -> dict[str, str]: + env = os.environ.copy() + env["VIBE_HOME"] = str(vibe_home) + env["TERM"] = env.get("TERM") or "xterm-256color" + env.pop("MISTRAL_API_KEY", None) + env.pop("VIBE_MISTRAL_API_KEY", None) + return env + + +def test_version(binary: Path) -> None: + result = subprocess.run( + [str(binary), "--version"], capture_output=True, text=True, timeout=30 + ) + if result.returncode != 0: + _fail( + f"--version exited with code {result.returncode}\nstderr: {result.stderr}" + ) + print(f"PASS: --version -> {result.stdout.strip()}") + + +def test_interactive_launch_loads_bundled_assets(binary: Path) -> None: + if platform.system() == "Windows": + # Windows does not provide the Unix pty module used to drive Textual here. + print("SKIP: interactive pty smoke test (Windows)") + return + + import pty + import select + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + workdir = tmp_path / "workdir" + workdir.mkdir() + master_fd, slave_fd = pty.openpty() + env = _isolated_env(tmp_path / "home") + env["MISTRAL_API_KEY"] = "smoke-test-mock-key" + proc = subprocess.Popen( + [str(binary), "--trust", "--workdir", str(workdir)], + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + env=env, + text=False, + ) + os.close(slave_fd) + + output = bytearray() + deadline = time.monotonic() + 30 + try: + while time.monotonic() < deadline: + if proc.poll() is not None: + break + + readable, _, _ = select.select([master_fd], [], [], 0.2) + if not readable: + continue + + chunk = os.read(master_fd, 4096) + if not chunk: + break + output.extend(chunk) + + decoded = output.decode("utf-8", errors="replace") + if "Traceback" in decoded or "StylesheetError" in decoded: + _fail(decoded) + + # This pins the Textual app title used as the main-app smoke marker. + if "\x1b]0;Vibe\x07" in decoded and len(output) > 4096: + print("PASS: interactive launch loaded bundled UI assets") + return + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + os.close(master_fd) + + _fail("interactive launch did not render expected UI output before timeout") + + +def test_setup_loads_bundled_assets(binary: Path) -> None: + if platform.system() == "Windows": + # Windows does not provide the Unix pty module used to drive Textual here. + print("SKIP: --setup pty smoke test (Windows)") + return + + import pty + import select + + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) / "workdir" + workdir.mkdir() + master_fd, slave_fd = pty.openpty() + proc = subprocess.Popen( + [str(binary), "--setup", "--workdir", str(workdir)], + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + env=_isolated_env(Path(tmp) / "home"), + text=False, + ) + os.close(slave_fd) + + output = bytearray() + deadline = time.monotonic() + 30 + try: + while time.monotonic() < deadline: + if proc.poll() is not None: + break + + readable, _, _ = select.select([master_fd], [], [], 0.2) + if not readable: + continue + + chunk = os.read(master_fd, 4096) + if not chunk: + break + output.extend(chunk) + + if b"Traceback" in output or b"StylesheetError" in output: + _fail(output.decode("utf-8", errors="replace")) + + if b"Mistral" in output or b"API" in output or b"Welcome" in output: + print("PASS: --setup loaded bundled UI assets") + return + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + os.close(master_fd) + + _fail("--setup did not render expected setup UI text before timeout") + + +def test_programmatic_missing_api_key(binary: Path) -> None: + with tempfile.TemporaryDirectory() as tmp: + result = subprocess.run( + [str(binary), "-p", "hello"], + capture_output=True, + env=_isolated_env(Path(tmp) / ".vibe"), + text=True, + timeout=30, + ) + + if result.returncode == 0: + _fail("programmatic mode without API key unexpectedly succeeded") + + output = f"{result.stdout}\n{result.stderr}" + if "Traceback" in output: + _fail(f"programmatic mode raised a traceback:\n{output}") + # This pins the user-facing guidance for the missing API key path. + if "Set the environment variable" not in output: + _fail(f"missing expected API key guidance:\n{output}") + + print("PASS: programmatic mode reports missing API key") + + +def test_bundled_runtime_files(binary_dir: Path) -> None: + bundle_root = binary_dir / "_internal" / "vibe" + source_root = Path(__file__).resolve().parents[2] / "vibe" + + if not bundle_root.is_dir(): + _fail(f"bundled vibe package not found at {bundle_root}") + if not source_root.is_dir(): + _fail(f"source vibe package not found at {source_root}") + + required_exact = [ + "whats_new.md", + "cli/textual_ui/app.tcss", + "setup/onboarding/onboarding.tcss", + "setup/trusted_folders/trust_folder_dialog.tcss", + ] + + missing_exact = [ + relative + for relative in required_exact + if not (bundle_root / relative).is_file() + ] + if missing_exact: + lines = ["Missing required bundled runtime file(s):"] + lines.extend(f" - vibe/{relative}" for relative in missing_exact) + _fail("\n".join(lines)) + + mirrored_globs = [ + "**/*.tcss", + "**/*.md", + "core/tools/builtins/*.py", + "core/skills/builtins/*.py", + ] + + for pattern in mirrored_globs: + source_files = { + path.relative_to(source_root) + for path in source_root.glob(pattern) + if path.is_file() + } + bundled_files = { + path.relative_to(bundle_root) + for path in bundle_root.glob(pattern) + if path.is_file() + } + + missing = sorted(source_files - bundled_files) + if missing: + lines = [f"Bundle is missing runtime file(s) for pattern {pattern}:"] + lines.extend(f" - vibe/{path}" for path in missing) + _fail("\n".join(lines)) + + print("PASS: bundled runtime files are present") + + +def test_installed_bundle_launches(binary_dir: Path, binary_name: str) -> None: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + install_dir = tmp_path / "install" / "vibe" + workdir = tmp_path / "workdir" + vibe_home = tmp_path / "home" + workdir.mkdir() + + shutil.copytree(binary_dir, install_dir) + installed_binary = install_dir / binary_name + if not installed_binary.exists(): + _fail(f"installed binary not found at {installed_binary}") + if platform.system() != "Windows": + installed_binary.chmod(0o755) + + env = _isolated_env(vibe_home) + env["PATH"] = f"{install_dir}{os.pathsep}{env.get('PATH', '')}" + + result = subprocess.run( + [binary_name, "--version"], + capture_output=True, + cwd=workdir, + env=env, + text=True, + timeout=30, + ) + + if result.returncode != 0: + _fail( + "installed bundle --version exited with code " + f"{result.returncode}\nstderr: {result.stderr}" + ) + if "Traceback" in f"{result.stdout}\n{result.stderr}": + _fail(f"installed bundle raised a traceback:\n{result.stdout}\n{result.stderr}") + + print(f"PASS: installed bundle launches from PATH -> {result.stdout.strip()}") + + +_PT_GNU_STACK = 0x6474E551 +_PF_X = 0x1 + + +def _has_executable_stack(filepath: Path) -> bool | None: + try: + with filepath.open("rb") as f: + magic = f.read(4) + if magic != b"\x7fELF": + return None + + ei_class = f.read(1)[0] + ei_data = f.read(1)[0] + + match ei_data: + case 1: + endian = "<" + case 2: + endian = ">" + case _: + return None + + if ei_class == 2: + f.seek(32) + (e_phoff,) = struct.unpack(f"{endian}Q", f.read(8)) + f.seek(54) + (e_phentsize,) = struct.unpack(f"{endian}H", f.read(2)) + (e_phnum,) = struct.unpack(f"{endian}H", f.read(2)) + + for i in range(e_phnum): + f.seek(e_phoff + i * e_phentsize) + (p_type,) = struct.unpack(f"{endian}I", f.read(4)) + (p_flags,) = struct.unpack(f"{endian}I", f.read(4)) + if p_type == _PT_GNU_STACK: + return bool(p_flags & _PF_X) + + elif ei_class == 1: + f.seek(28) + (e_phoff,) = struct.unpack(f"{endian}I", f.read(4)) + f.seek(42) + (e_phentsize,) = struct.unpack(f"{endian}H", f.read(2)) + (e_phnum,) = struct.unpack(f"{endian}H", f.read(2)) + + for i in range(e_phnum): + off = e_phoff + i * e_phentsize + f.seek(off) + (p_type,) = struct.unpack(f"{endian}I", f.read(4)) + f.seek(off + 24) + (p_flags,) = struct.unpack(f"{endian}I", f.read(4)) + if p_type == _PT_GNU_STACK: + return bool(p_flags & _PF_X) + + return False + except (OSError, struct.error): + return None + + +def test_no_executable_stack(binary_dir: Path, binary_name: str) -> None: + if platform.system() != "Linux": + print("SKIP: executable stack check (not Linux)") + return + + internal_dir = binary_dir / "_internal" + if not internal_dir.exists(): + _fail(f"_internal directory not found at {internal_dir}") + + violations: list[Path] = [] + checked = 0 + candidates = [binary_dir / binary_name, *internal_dir.rglob("*")] + + for filepath in candidates: + if not filepath.is_file(): + continue + result = _has_executable_stack(filepath) + if result is None: + continue + checked += 1 + if result: + violations.append(filepath) + + if violations: + lines = [ + f"Found {len(violations)} ELF file(s) with executable stack " + f"(GNU_STACK RWE) out of {checked} checked.", + "", + "These will fail on SELinux-enforcing or hardened kernels:", + ] + for violation in violations: + lines.append(f" - {violation.relative_to(binary_dir)}") + lines.append("") + lines.append("Fix: run 'patchelf --clear-execstack' on these files.") + _fail("\n".join(lines)) + + print(f"PASS: no executable stack in {checked} ELF files") + + +def main() -> None: + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + + binary_dir = Path(sys.argv[1]) + binary_name = "vibe.exe" if platform.system() == "Windows" else "vibe" + binary = binary_dir / binary_name + + if not binary.exists(): + _fail(f"binary not found at {binary}") + + if platform.system() != "Windows": + binary.chmod(0o755) + + print(f"Testing binary: {binary}\n") + + test_version(binary) + test_bundled_runtime_files(binary_dir) + test_installed_bundle_launches(binary_dir, binary_name) + test_no_executable_stack(binary_dir, binary_name) + test_interactive_launch_loads_bundled_assets(binary) + test_setup_loads_bundled_assets(binary) + test_programmatic_missing_api_key(binary) + + print("\nAll smoke tests passed!") + + +if __name__ == "__main__": + main() diff --git a/tests/cli/test_check_and_resolve_trusted_folder.py b/tests/cli/test_check_and_resolve_trusted_folder.py new file mode 100644 index 0000000..6feb4ba --- /dev/null +++ b/tests/cli/test_check_and_resolve_trusted_folder.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +import pytest + +from vibe.cli import entrypoint as entrypoint_mod +from vibe.core.trusted_folders import trusted_folders_manager +from vibe.setup.trusted_folders.trust_folder_dialog import TrustDecision + + +def _make_git_repo(path: Path) -> None: + git_dir = path / ".git" + git_dir.mkdir() + (git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8") + + +def _git_sub_with_agents( + tmp_path: Path, *, agents_at_sub: bool = True +) -> tuple[Path, Path]: + repo = tmp_path / "repo" + repo.mkdir() + _make_git_repo(repo) + sub = repo / "src" + sub.mkdir() + if agents_at_sub: + (sub / "AGENTS.md").write_text("# Agents", encoding="utf-8") + else: + (repo / "AGENTS.md").write_text("# Agents", encoding="utf-8") + return repo, sub + + +def _patch_ask( + monkeypatch: pytest.MonkeyPatch, *, decision: TrustDecision = TrustDecision.DECLINE +) -> dict[str, object]: + captured: dict[str, object] = {"called": False} + + def fake_ask( + cwd: Path, + repo_root: Path | None, + detected_files: list[str], + repo_detected_files: list[str] | None = None, + offer_repo_trust: bool = False, + repo_explicitly_untrusted: bool = False, + ) -> TrustDecision: + captured["called"] = True + captured["cwd"] = cwd + captured["repo_root"] = repo_root + captured["detected_files"] = detected_files + captured["repo_detected_files"] = repo_detected_files + captured["offer_repo_trust"] = offer_repo_trust + captured["repo_explicitly_untrusted"] = repo_explicitly_untrusted + return decision + + monkeypatch.setattr(entrypoint_mod, "ask_trust_folder", fake_ask) + return captured + + +@pytest.fixture +def away_from_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(Path, "home", classmethod(lambda _cls: tmp_path / "elsewhere")) + + +@pytest.mark.parametrize( + "setup", + [ + pytest.param("repo_already_trusted", id="repo_already_trusted"), + pytest.param("cwd_explicitly_untrusted", id="cwd_explicitly_untrusted"), + ], +) +def test_skips_trust_dialog( + setup: Literal["repo_already_trusted", "cwd_explicitly_untrusted"], + tmp_path: Path, + away_from_home: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + match setup: + case "repo_already_trusted": + repo, cwd = _git_sub_with_agents(tmp_path) + trusted_folders_manager.add_trusted(repo) + case "cwd_explicitly_untrusted": + cwd = tmp_path / "plain" + cwd.mkdir() + (cwd / "AGENTS.md").write_text("# Agents", encoding="utf-8") + trusted_folders_manager.add_untrusted(cwd) + + captured = _patch_ask(monkeypatch) + entrypoint_mod.check_and_resolve_trusted_folder(cwd) + assert captured["called"] is False + + +def test_shows_trust_dialog_when_only_repo_context_files_exist( + tmp_path: Path, away_from_home: None, monkeypatch: pytest.MonkeyPatch +) -> None: + repo, cwd = _git_sub_with_agents(tmp_path, agents_at_sub=False) + captured = _patch_ask(monkeypatch) + entrypoint_mod.check_and_resolve_trusted_folder(cwd) + + assert captured["called"] is True + assert captured["detected_files"] == [] + assert captured["repo_detected_files"] == ["AGENTS.md"] + assert captured["repo_root"] == repo.resolve() + + +@pytest.mark.parametrize( + "untrusted_at,offer_repo_trust,repo_explicitly_untrusted", + [ + pytest.param(None, True, False, id="repo_undecided"), + pytest.param("repo", False, True, id="repo_explicitly_untrusted"), + pytest.param("parent", True, False, id="parent_untrusted_only"), + ], +) +def test_ask_trust_folder_args_in_git_repo( + untrusted_at: str | None, + offer_repo_trust: bool, + repo_explicitly_untrusted: bool, + tmp_path: Path, + away_from_home: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + if untrusted_at == "parent": + parent = tmp_path / "parent" + parent.mkdir() + repo = parent / "repo" + repo.mkdir() + _make_git_repo(repo) + cwd = repo / "src" + cwd.mkdir() + (cwd / "AGENTS.md").write_text("# Agents", encoding="utf-8") + trusted_folders_manager.add_untrusted(parent) + else: + repo, cwd = _git_sub_with_agents(tmp_path) + if untrusted_at == "repo": + trusted_folders_manager.add_untrusted(repo) + + captured = _patch_ask(monkeypatch) + entrypoint_mod.check_and_resolve_trusted_folder(cwd) + + assert captured["called"] is True + assert captured["cwd"] == cwd + assert captured["repo_root"] == repo.resolve() + assert captured["detected_files"] == ["AGENTS.md"] + assert captured["repo_detected_files"] == [] + assert captured["offer_repo_trust"] is offer_repo_trust + assert captured["repo_explicitly_untrusted"] is repo_explicitly_untrusted + + +@pytest.mark.parametrize( + ("decision", "sub_trusted", "repo_trusted", "sub_explicitly_untrusted"), + [ + pytest.param(TrustDecision.TRUST_REPO, True, True, False, id="trust_repo"), + pytest.param(TrustDecision.TRUST_CWD, True, None, False, id="trust_cwd"), + pytest.param(TrustDecision.DECLINE, False, None, True, id="decline"), + ], +) +def test_applies_trust_decision( + decision: TrustDecision, + sub_trusted: bool | None, + repo_trusted: bool | None, + sub_explicitly_untrusted: bool, + tmp_path: Path, + away_from_home: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo, sub = _git_sub_with_agents(tmp_path) + _patch_ask(monkeypatch, decision=decision) + entrypoint_mod.check_and_resolve_trusted_folder(sub) + + assert trusted_folders_manager.is_trusted(sub) is sub_trusted + assert trusted_folders_manager.is_trusted(repo) is repo_trusted + assert ( + trusted_folders_manager.is_explicitly_untrusted(sub) is sub_explicitly_untrusted + ) + if decision == TrustDecision.TRUST_CWD: + assert trusted_folders_manager.is_trusted(repo / "other") is None + + +def test_no_git_repo_offers_no_repo_trust_and_decline_untrusts_cwd( + tmp_path: Path, away_from_home: None, monkeypatch: pytest.MonkeyPatch +) -> None: + cwd = tmp_path / "plain" + cwd.mkdir() + (cwd / "AGENTS.md").write_text("# Agents", encoding="utf-8") + + captured = _patch_ask(monkeypatch, decision=TrustDecision.DECLINE) + entrypoint_mod.check_and_resolve_trusted_folder(cwd) + + assert captured["called"] is True + assert captured["repo_root"] is None + assert captured["repo_detected_files"] == [] + assert captured["offer_repo_trust"] is False + assert trusted_folders_manager.is_explicitly_untrusted(cwd) is True diff --git a/tests/cli/test_clipboard.py b/tests/cli/test_clipboard.py index b3c74c7..2032801 100644 --- a/tests/cli/test_clipboard.py +++ b/tests/cli/test_clipboard.py @@ -18,6 +18,7 @@ from vibe.cli.clipboard import ( _read_clipboard, copy_selection_to_clipboard, copy_text_to_clipboard, + try_copy_text_to_clipboard, ) @@ -206,6 +207,32 @@ def test_copy_text_to_clipboard_shows_failure_when_clipboard_unavailable( ) +@patch("vibe.cli.clipboard._copy_to_clipboard") +def test_try_copy_text_to_clipboard_returns_true_when_copy_succeeds( + mock_copy_to_clipboard: MagicMock, +) -> None: + result = try_copy_text_to_clipboard("assistant text") + + assert result is True + mock_copy_to_clipboard.assert_called_once_with("assistant text") + + +@patch("vibe.cli.clipboard._copy_to_clipboard") +def test_try_copy_text_to_clipboard_returns_false_when_clipboard_unavailable( + mock_copy_to_clipboard: MagicMock, +) -> None: + mock_copy_to_clipboard.side_effect = RuntimeError("All clipboard strategies failed") + + result = try_copy_text_to_clipboard("assistant text") + + assert result is False + mock_copy_to_clipboard.assert_called_once_with("assistant text") + + +def test_try_copy_text_to_clipboard_returns_false_for_empty_text() -> None: + assert try_copy_text_to_clipboard("") is False + + def test_copy_text_to_clipboard_returns_none_for_empty_text( mock_app: MagicMock, ) -> None: diff --git a/tests/cli/test_mcp_app.py b/tests/cli/test_mcp_app.py index 66607d5..7824964 100644 --- a/tests/cli/test_mcp_app.py +++ b/tests/cli/test_mcp_app.py @@ -246,7 +246,7 @@ class TestConnectorMenuOrdering: ) ordered = _sort_connector_names_for_menu( - registry.get_connector_names(), registry + registry.get_connector_names(), registry, set() ) assert ordered == ["alpha", "beta", "zeta"] @@ -261,11 +261,54 @@ class TestConnectorMenuOrdering: ) ordered = _sort_connector_names_for_menu( - registry.get_connector_names(), registry + registry.get_connector_names(), registry, set() ) assert ordered == ["alpha", "Beta", "Zeta"] + def test_disabled_connectors_sink_to_bottom(self) -> None: + registry = FakeConnectorRegistry( + connectors={ + "alpha": [RemoteTool(name="lookup", description="Lookup")], + "beta": [RemoteTool(name="search", description="Search")], + "gamma": [], + } + ) + + # alpha is enabled+connected, beta is enabled+connected but disabled in + # config, gamma is enabled+disconnected. Expected: enabled-and-connected + # first, then enabled-but-disconnected, then disabled at the bottom. + ordered = _sort_connector_names_for_menu( + registry.get_connector_names(), registry, {"beta"} + ) + + assert ordered == ["alpha", "gamma", "beta"] + + def test_full_ordering_enabled_then_connected_then_alpha(self) -> None: + registry = FakeConnectorRegistry( + connectors={ + "d_disabled_connected": [RemoteTool(name="t", description="t")], + "b_enabled_disconnected": [], + "a_enabled_connected": [RemoteTool(name="t", description="t")], + "c_enabled_connected": [RemoteTool(name="t", description="t")], + "e_disabled_disconnected": [], + } + ) + + ordered = _sort_connector_names_for_menu( + registry.get_connector_names(), + registry, + {"d_disabled_connected", "e_disabled_disconnected"}, + ) + + assert ordered == [ + "a_enabled_connected", + "c_enabled_connected", + "b_enabled_disconnected", + "d_disabled_connected", + "e_disabled_disconnected", + ] + class TestHelpBarChanges: """The help bar should show 'Enter Authenticate' for disconnected connectors.""" diff --git a/tests/cli/test_ui_session_resume.py b/tests/cli/test_ui_session_resume.py index 276dde8..3eb5c6e 100644 --- a/tests/cli/test_ui_session_resume.py +++ b/tests/cli/test_ui_session_resume.py @@ -45,7 +45,7 @@ async def test_ui_displays_messages_when_resuming_session( id="tool_call_1", index=0, function=FunctionCall( - name="read_file", arguments='{"path": "test.txt"}' + name="read", arguments='{"file_path": "test.txt"}' ), ) ], @@ -53,7 +53,7 @@ async def test_ui_displays_messages_when_resuming_session( tool_result_msg = LLMMessage( role=Role.tool, content="File content here", - name="read_file", + name="read", tool_call_id="tool_call_1", ) @@ -78,12 +78,12 @@ async def test_ui_displays_messages_when_resuming_session( # Verify tool call message is displayed tool_call_messages = app.query(ToolCallMessage) assert len(tool_call_messages) == 1 - assert tool_call_messages[0]._tool_name == "read_file" + assert tool_call_messages[0]._tool_name == "read" # Verify tool result message is displayed tool_result_messages = app.query(ToolResultMessage) assert len(tool_result_messages) == 1 - assert tool_result_messages[0].tool_name == "read_file" + assert tool_result_messages[0].tool_name == "read" assert tool_result_messages[0]._content == "File content here" diff --git a/tests/cli/textual_ui/test_chat_input_keybindings.py b/tests/cli/textual_ui/test_chat_input_keybindings.py new file mode 100644 index 0000000..d7faa68 --- /dev/null +++ b/tests/cli/textual_ui/test_chat_input_keybindings.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import pytest + +from tests.conftest import build_test_vibe_app +from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer, ChatTextArea + + +@pytest.mark.asyncio +async def test_shift_backspace_deletes_character_like_backspace() -> None: + app = build_test_vibe_app() + async with app.run_test() as pilot: + await pilot.pause(0.1) + + text_area = app.query_one(ChatTextArea) + text_area.focus() + await pilot.pause(0.1) + + await pilot.press("a", "b", "c") + await pilot.pause(0.1) + assert app.query_one(ChatInputContainer).value == "abc" + + await pilot.press("shift+backspace") + await pilot.pause(0.1) + + assert app.query_one(ChatInputContainer).value == "ab" + + +@pytest.mark.asyncio +async def test_shift_delete_deletes_character_like_delete() -> None: + app = build_test_vibe_app() + async with app.run_test() as pilot: + await pilot.pause(0.1) + + text_area = app.query_one(ChatTextArea) + text_area.focus() + await pilot.pause(0.1) + + await pilot.press("a", "b", "c", "left") + await pilot.pause(0.1) + assert app.query_one(ChatInputContainer).value == "abc" + + await pilot.press("shift+delete") + await pilot.pause(0.1) + + assert app.query_one(ChatInputContainer).value == "ab" + + +@pytest.mark.asyncio +async def test_shift_backspace_resets_mode_when_empty() -> None: + app = build_test_vibe_app() + async with app.run_test() as pilot: + await pilot.pause(0.1) + + text_area = app.query_one(ChatTextArea) + text_area.focus() + text_area.set_mode("!") + await pilot.pause(0.1) + assert text_area.input_mode == "!" + + await pilot.press("shift+backspace") + await pilot.pause(0.1) + + assert text_area.input_mode == ">" diff --git a/tests/cli/textual_ui/test_image_input_validation.py b/tests/cli/textual_ui/test_image_input_validation.py new file mode 100644 index 0000000..be094c2 --- /dev/null +++ b/tests/cli/textual_ui/test_image_input_validation.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.conftest import build_test_vibe_app, build_test_vibe_config +from vibe.cli.textual_ui.app import _ImageAttachmentRejection +from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer +from vibe.cli.textual_ui.widgets.messages import ErrorMessage +from vibe.core.autocompletion.path_prompt import build_path_prompt_payload +from vibe.core.config import ModelConfig, ProviderConfig +from vibe.core.types import ( + MAX_IMAGE_BYTES, + MAX_IMAGES_PER_MESSAGE, + Backend, + ImageAttachment, +) + +PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 + + +def _vision_config(*, supports_images: bool = True): + models = [ + ModelConfig( + name="mistral-vibe-cli-latest", + provider="mistral", + alias="devstral-latest", + supports_images=supports_images, + ) + ] + providers = [ + ProviderConfig( + name="mistral", + api_base="https://api.mistral.ai/v1", + api_key_env_var="MISTRAL_API_KEY", + backend=Backend.MISTRAL, + ) + ] + return build_test_vibe_config( + active_model="devstral-latest", models=models, providers=providers + ) + + +@pytest.mark.asyncio +async def test_build_image_attachments_happy_path(tmp_path: Path) -> None: + (tmp_path / "shot.png").write_bytes(PNG_BYTES) + payload = build_path_prompt_payload("look at @shot.png", base_dir=tmp_path) + + app = build_test_vibe_app(config=_vision_config()) + result = await app._build_image_attachments(payload) + + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], ImageAttachment) + assert result[0].alias == "shot.png" + assert result[0].mime_type == "image/png" + + +@pytest.mark.asyncio +async def test_build_image_attachments_returns_empty_when_no_images( + tmp_path: Path, +) -> None: + (tmp_path / "notes.md").write_text("hi") + payload = build_path_prompt_payload("read @notes.md", base_dir=tmp_path) + + app = build_test_vibe_app(config=_vision_config()) + result = await app._build_image_attachments(payload) + + assert result == [] + + +@pytest.mark.asyncio +async def test_build_image_attachments_rejects_too_many_images(tmp_path: Path) -> None: + mentions = [] + for i in range(MAX_IMAGES_PER_MESSAGE + 1): + name = f"img{i}.png" + (tmp_path / name).write_bytes(PNG_BYTES) + mentions.append(f"@{name}") + payload = build_path_prompt_payload(" ".join(mentions), base_dir=tmp_path) + + app = build_test_vibe_app(config=_vision_config()) + result = await app._build_image_attachments(payload) + + assert isinstance(result, _ImageAttachmentRejection) + assert not result.no_vision + assert "Too many image attachments" in result.message + assert str(MAX_IMAGES_PER_MESSAGE) in result.message + + +@pytest.mark.asyncio +async def test_build_image_attachments_rejects_non_vision_model(tmp_path: Path) -> None: + (tmp_path / "shot.png").write_bytes(PNG_BYTES) + payload = build_path_prompt_payload("look at @shot.png", base_dir=tmp_path) + + app = build_test_vibe_app(config=_vision_config(supports_images=False)) + result = await app._build_image_attachments(payload) + + assert isinstance(result, _ImageAttachmentRejection) + assert result.no_vision + assert "does not support images" in result.message + assert "devstral-latest" in result.message + + +@pytest.mark.asyncio +async def test_build_image_attachments_rejects_oversize_image( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Patch the cap down so we don't need to write 10MB to disk. + monkeypatch.setattr("vibe.cli.textual_ui.app.MAX_IMAGE_BYTES", 32) + + (tmp_path / "shot.png").write_bytes(PNG_BYTES + b"\x00" * 64) + payload = build_path_prompt_payload("look at @shot.png", base_dir=tmp_path) + + app = build_test_vibe_app(config=_vision_config()) + result = await app._build_image_attachments(payload) + + assert isinstance(result, _ImageAttachmentRejection) + assert not result.no_vision + assert "shot.png" in result.message + assert "max" in result.message.lower() + + +def test_max_image_bytes_default_is_10_mib() -> None: + assert MAX_IMAGE_BYTES == 10 * 1024 * 1024 + + +def test_max_images_per_message_default_is_8() -> None: + assert MAX_IMAGES_PER_MESSAGE == 8 + + +@pytest.mark.asyncio +async def test_submit_restores_input_when_image_validation_fails( + tmp_path: Path, +) -> None: + (tmp_path / "shot.png").write_bytes(PNG_BYTES) + app = build_test_vibe_app(config=_vision_config(supports_images=False)) + typed = f"look at @{tmp_path / 'shot.png'}" + + async with app.run_test() as pilot: + chat_input = app.query_one(ChatInputContainer) + chat_input.value = typed + await pilot.press("enter") + await pilot.pause() + + assert chat_input.value == typed + errors = list(app.query(ErrorMessage)) + assert errors + assert all(not e._show_border for e in errors) diff --git a/tests/cli/textual_ui/test_paste_path.py b/tests/cli/textual_ui/test_paste_path.py new file mode 100644 index 0000000..23431dd --- /dev/null +++ b/tests/cli/textual_ui/test_paste_path.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from textual import events + +from vibe.cli.textual_ui.app import VibeApp +from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer +from vibe.cli.textual_ui.widgets.chat_input.paste_path import ( + maybe_prepend_at_for_image_path, + rewrite_bare_image_paths_in_text, +) +from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea + + +def test_bare_absolute_image_path_gets_at_prefix(tmp_path: Path) -> None: + img = tmp_path / "shot.png" + img.write_bytes(b"\x89PNG") + + rewritten = maybe_prepend_at_for_image_path(str(img)) + + assert rewritten == f"@{img}" + + +def test_quoted_image_path_with_spaces_is_unwrapped_and_quoted(tmp_path: Path) -> None: + img = tmp_path / "has space.png" + img.write_bytes(b"\x89PNG") + pasted = f"'{img}'" + + rewritten = maybe_prepend_at_for_image_path(pasted) + + assert rewritten == f"@'{img}'" + + +def test_backslash_escaped_image_path_is_unescaped_and_quoted(tmp_path: Path) -> None: + img = tmp_path / "has space.png" + img.write_bytes(b"\x89PNG") + pasted = str(img).replace(" ", "\\ ") + + rewritten = maybe_prepend_at_for_image_path(pasted) + + assert rewritten == f"@'{img}'" + + +def test_non_image_file_path_is_left_untouched(tmp_path: Path) -> None: + txt = tmp_path / "readme.md" + txt.write_text("hi") + + rewritten = maybe_prepend_at_for_image_path(str(txt)) + + assert rewritten == str(txt) + + +def test_missing_image_path_is_left_untouched(tmp_path: Path) -> None: + missing = tmp_path / "nope.png" + + rewritten = maybe_prepend_at_for_image_path(str(missing)) + + assert rewritten == str(missing) + + +def test_unresolvable_tilde_user_does_not_crash() -> None: + # `~a` raises RuntimeError from Path.expanduser() when user `a` does not + # exist; the rewrite hook must swallow it so every keystroke after `~` + # does not crash the TUI. + assert maybe_prepend_at_for_image_path("~a") == "~a" + assert rewrite_bare_image_paths_in_text("hello ~a world") == "hello ~a world" + + +def test_multiline_paste_is_left_untouched(tmp_path: Path) -> None: + img = tmp_path / "shot.png" + img.write_bytes(b"\x89PNG") + pasted = f"{img}\nother line" + + assert maybe_prepend_at_for_image_path(pasted) == pasted + + +def test_relative_path_is_left_untouched(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "shot.png").write_bytes(b"\x89PNG") + + assert maybe_prepend_at_for_image_path("shot.png") == "shot.png" + + +def test_already_at_prefixed_path_is_left_untouched(tmp_path: Path) -> None: + img = tmp_path / "shot.png" + img.write_bytes(b"\x89PNG") + + pasted = f"@{img}" + assert maybe_prepend_at_for_image_path(pasted) == pasted + + +@pytest.mark.asyncio +async def test_paste_event_inserts_at_prefixed_path_into_chat_input( + vibe_app: VibeApp, tmp_path: Path +) -> None: + img = tmp_path / "shot.png" + img.write_bytes(b"\x89PNG") + + async with vibe_app.run_test() as pilot: + chat_input = vibe_app.query_one(ChatInputContainer) + text_area = chat_input.query_one(ChatTextArea) + text_area.focus() + text_area.post_message(events.Paste(text=str(img))) + await pilot.pause() + + assert chat_input.value == f"@{img}" + + +@pytest.mark.asyncio +async def test_paste_event_leaves_non_image_paths_untouched( + vibe_app: VibeApp, tmp_path: Path +) -> None: + txt = tmp_path / "notes.md" + txt.write_text("hi") + + async with vibe_app.run_test() as pilot: + chat_input = vibe_app.query_one(ChatInputContainer) + text_area = chat_input.query_one(ChatTextArea) + text_area.focus() + text_area.post_message(events.Paste(text=str(txt))) + await pilot.pause() + + assert chat_input.value == str(txt) + + +def test_rewrite_bare_image_paths_handles_bare_path(tmp_path: Path) -> None: + img = tmp_path / "shot.png" + img.write_bytes(b"\x89PNG") + + rewritten = rewrite_bare_image_paths_in_text(f"look at {img} please") + + assert rewritten == f"look at @{img} please" + + +def test_rewrite_bare_image_paths_handles_quoted_path(tmp_path: Path) -> None: + img = tmp_path / "has space.png" + img.write_bytes(b"\x89PNG") + + rewritten = rewrite_bare_image_paths_in_text(f"look at '{img}'") + + assert rewritten == f"look at @'{img}'" + + +def test_rewrite_bare_image_paths_is_idempotent(tmp_path: Path) -> None: + img = tmp_path / "shot.png" + img.write_bytes(b"\x89PNG") + + once = rewrite_bare_image_paths_in_text(f"see {img}") + twice = rewrite_bare_image_paths_in_text(once) + + assert once == twice == f"see @{img}" + + +def test_rewrite_bare_image_paths_skips_non_image(tmp_path: Path) -> None: + txt = tmp_path / "notes.md" + txt.write_text("hi") + + rewritten = rewrite_bare_image_paths_in_text(f"see {txt}") + + assert rewritten == f"see {txt}" + + +def test_rewrite_bare_image_paths_fast_path_skips_stat_for_plain_text( + monkeypatch, +) -> None: + from pathlib import Path as _Path + + calls = 0 + original = _Path.is_file + + def _counting_is_file(self): + nonlocal calls + calls += 1 + return original(self) + + monkeypatch.setattr(_Path, "is_file", _counting_is_file) + + rewrite_bare_image_paths_in_text("hello world, nothing path-shaped here") + rewrite_bare_image_paths_in_text("multi\nline\ntext\nwith no slash") + + assert calls == 0 + + +@pytest.mark.asyncio +async def test_text_change_hook_rewrites_quoted_image_path( + vibe_app: VibeApp, tmp_path: Path +) -> None: + img = tmp_path / "shot.png" + img.write_bytes(b"\x89PNG") + + async with vibe_app.run_test() as pilot: + chat_input = vibe_app.query_one(ChatInputContainer) + text_area = chat_input.query_one(ChatTextArea) + text_area.focus() + # Simulate a non-bracketed-paste insertion (terminal that does + # not emit Paste): set the text directly the way a bulk insert + # would land it. + text_area.text = f"'{img}'" + await pilot.pause() + + # No spaces in the path -> the scanner emits an unquoted `@`. + assert chat_input.value == f"@{img}" + + +@pytest.mark.asyncio +async def test_text_change_hook_rewrites_quoted_image_path_with_spaces( + vibe_app: VibeApp, tmp_path: Path +) -> None: + img = tmp_path / "has space.png" + img.write_bytes(b"\x89PNG") + + async with vibe_app.run_test() as pilot: + chat_input = vibe_app.query_one(ChatInputContainer) + text_area = chat_input.query_one(ChatTextArea) + text_area.focus() + text_area.text = f"'{img}'" + await pilot.pause() + + assert chat_input.value == f"@'{img}'" diff --git a/tests/cli/textual_ui/test_read_widget.py b/tests/cli/textual_ui/test_read_widget.py new file mode 100644 index 0000000..c9192ee --- /dev/null +++ b/tests/cli/textual_ui/test_read_widget.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from vibe.cli.textual_ui.widgets.tool_widgets import _strip_line_numbers + + +def test_strips_numbered_prefixes() -> None: + content = " 1→first\n 42→second\n 100→third" + assert _strip_line_numbers(content) == "first\nsecond\nthird" + + +def test_leaves_warning_lines_untouched() -> None: + content = "Warning: the file exists but the contents are empty." + assert _strip_line_numbers(content) == content + + +def test_preserves_arrows_inside_content() -> None: + content = " 1→a → b → c" + assert _strip_line_numbers(content) == "a → b → c" diff --git a/tests/cli/textual_ui/test_user_message_attachments.py b/tests/cli/textual_ui/test_user_message_attachments.py new file mode 100644 index 0000000..8820ea5 --- /dev/null +++ b/tests/cli/textual_ui/test_user_message_attachments.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from pathlib import Path +from weakref import WeakKeyDictionary + +from vibe.cli.textual_ui.widgets.messages import UserMessage +from vibe.cli.textual_ui.windowing.history import build_history_widgets +from vibe.core.types import ImageAttachment, LLMMessage, Role + + +def _att(path: Path, alias: str) -> ImageAttachment: + path.write_bytes(b"\x89PNG") + return ImageAttachment(path=path, alias=alias, mime_type="image/png") + + +def test_attachments_footer_singular(tmp_path: Path) -> None: + att = _att(tmp_path / "shot.png", "shot.png") + + rendered = UserMessage._format_attachments_footer([att]) + + assert "attached image:" in rendered + assert '[link="file://' in rendered + assert "]shot.png[/link]" in rendered + + +def test_attachments_footer_plural(tmp_path: Path) -> None: + a = _att(tmp_path / "a.png", "a.png") + b = _att(tmp_path / "b.png", "b.png") + + rendered = UserMessage._format_attachments_footer([a, b]) + + assert "attached images:" in rendered + assert rendered.count('[link="file://') == 2 + assert "a.png" in rendered + assert "b.png" in rendered + + +def test_attachments_footer_escapes_alias_brackets(tmp_path: Path) -> None: + att = _att(tmp_path / "shot.png", "weird [bracket].png") + + rendered = UserMessage._format_attachments_footer([att]) + + # Rich's escape() turns "[" into "\[". + assert "\\[bracket]" in rendered + + +def test_resumed_user_message_with_images_renders_footer(tmp_path: Path) -> None: + att = _att(tmp_path / "shot.png", "shot.png") + msg = LLMMessage(role=Role.user, content="look at @shot.png", images=[att]) + + widgets = build_history_widgets( + [msg], + tool_call_map={}, + start_index=0, + tools_collapsed=False, + history_widget_indices=WeakKeyDictionary(), + ) + + assert len(widgets) == 1 + user_widget = widgets[0] + assert isinstance(user_widget, UserMessage) + assert user_widget._images == [att] + + +def test_resumed_user_message_with_images_only_still_mounts(tmp_path: Path) -> None: + att = _att(tmp_path / "shot.png", "shot.png") + msg = LLMMessage(role=Role.user, content="", images=[att]) + + widgets = build_history_widgets( + [msg], + tool_call_map={}, + start_index=0, + tools_collapsed=False, + history_widget_indices=WeakKeyDictionary(), + ) + + assert len(widgets) == 1 + assert isinstance(widgets[0], UserMessage) diff --git a/tests/core/session/test_image_snapshot.py b/tests/core/session/test_image_snapshot.py new file mode 100644 index 0000000..4e1dc51 --- /dev/null +++ b/tests/core/session/test_image_snapshot.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path + +import pytest + +from vibe.core.session.image_snapshot import ImageSnapshotError, snapshot_image + +PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 + + +def test_snapshot_image_copies_to_attachments_dir(tmp_path: Path) -> None: + src = tmp_path / "screenshot.png" + src.write_bytes(PNG_BYTES) + session_dir = tmp_path / "session" + session_dir.mkdir() + + att = snapshot_image(src, alias="screenshot.png", session_dir=session_dir) + + digest = hashlib.sha1(PNG_BYTES, usedforsecurity=False).hexdigest() + assert att.path == (session_dir / "attachments" / f"{digest}.png").resolve() + assert att.alias == "screenshot.png" + assert att.mime_type == "image/png" + assert att.path.read_bytes() == PNG_BYTES + + +def test_snapshot_image_is_idempotent_on_same_bytes(tmp_path: Path) -> None: + src_a = tmp_path / "a.png" + src_b = tmp_path / "b.png" + src_a.write_bytes(PNG_BYTES) + src_b.write_bytes(PNG_BYTES) + session_dir = tmp_path / "session" + + att_a = snapshot_image(src_a, alias="a.png", session_dir=session_dir) + att_b = snapshot_image(src_b, alias="b.png", session_dir=session_dir) + + assert att_a.path == att_b.path + assert sum(1 for _ in (session_dir / "attachments").iterdir()) == 1 + + +def test_snapshot_image_returns_source_when_session_dir_is_none(tmp_path: Path) -> None: + src = tmp_path / "screenshot.png" + src.write_bytes(PNG_BYTES) + + att = snapshot_image(src, alias="screenshot.png", session_dir=None) + + assert att.path == src.resolve() + assert att.alias == "screenshot.png" + + +def test_snapshot_image_rejects_non_image_extension(tmp_path: Path) -> None: + src = tmp_path / "readme.txt" + src.write_bytes(b"hi") + + with pytest.raises(ImageSnapshotError): + snapshot_image(src, alias="readme.txt", session_dir=None) + + +def test_snapshot_image_rejects_missing_file(tmp_path: Path) -> None: + with pytest.raises(ImageSnapshotError): + snapshot_image(tmp_path / "missing.png", alias="missing.png", session_dir=None) + + +def test_snapshot_image_normalizes_jpg_to_jpeg_mime(tmp_path: Path) -> None: + src = tmp_path / "photo.jpg" + src.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 8) + + att = snapshot_image(src, alias="photo.jpg", session_dir=None) + + assert att.mime_type == "image/jpeg" diff --git a/tests/core/session/test_last_session_pointer.py b/tests/core/session/test_last_session_pointer.py index 3c68cc0..74a6825 100644 --- a/tests/core/session/test_last_session_pointer.py +++ b/tests/core/session/test_last_session_pointer.py @@ -80,6 +80,36 @@ def test_record_ignores_empty_session_id( assert last_session_pointer.load(session_logging) is None +def test_clear_matching_removes_matching_pointers_only( + session_logging: SessionLoggingConfig, +) -> None: + pointer_dir = Path(session_logging.save_dir) / last_session_pointer.POINTER_DIR_NAME + pointer_dir.mkdir() + (pointer_dir / "ttys001").write_text("deleted-session\n", encoding="utf-8") + (pointer_dir / "ttys002").write_text("other-session\n", encoding="utf-8") + (pointer_dir / "ttys003").write_text("deleted-session\n", encoding="utf-8") + (pointer_dir / "nested").mkdir() + + last_session_pointer.clear_matching(session_logging, "deleted-session") + + assert not (pointer_dir / "ttys001").exists() + assert (pointer_dir / "ttys002").read_text(encoding="utf-8") == "other-session\n" + assert not (pointer_dir / "ttys003").exists() + assert (pointer_dir / "nested").is_dir() + + +def test_clear_matching_skips_when_logging_disabled(tmp_path: Path) -> None: + disabled = SessionLoggingConfig(save_dir=str(tmp_path), enabled=False) + pointer_dir = tmp_path / last_session_pointer.POINTER_DIR_NAME + pointer_dir.mkdir() + pointer_path = pointer_dir / "ttys001" + pointer_path.write_text("deleted-session\n", encoding="utf-8") + + last_session_pointer.clear_matching(disabled, "deleted-session") + + assert pointer_path.exists() + + def test_current_tty_key_returns_none_when_ttyname_is_unavailable( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/core/test_add_dir.py b/tests/core/test_add_dir.py index 1b273f8..7e2a950 100644 --- a/tests/core/test_add_dir.py +++ b/tests/core/test_add_dir.py @@ -344,13 +344,13 @@ class TestProjectPromptsDirsAdditionalDirs: class TestProjectRootsNestedDedup: - def test_nested_add_dirs_collapse(self, tmp_path: Path) -> None: + def test_nested_add_dirs_preserved(self, tmp_path: Path) -> None: outer = (tmp_path / "outer").resolve() inner = outer / "inner" inner.mkdir(parents=True) mgr = HarnessFilesManager(sources=("user",), _additional_dirs=(outer, inner)) - assert mgr.project_roots == [outer] + assert mgr.project_roots == [outer, inner] def test_add_dir_containing_cwd_keeps_both( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -380,10 +380,13 @@ class TestProjectRootsNestedDedup: outer = (tmp_path / "outer").resolve() inner = outer / "inner" inner.mkdir(parents=True) + skills_dir = inner / ".vibe" / "skills" + skills_dir.mkdir(parents=True) monkeypatch.chdir(outer) trusted_folders_manager.trust_for_session(outer) mgr = HarnessFilesManager( sources=("user", "project"), _additional_dirs=(inner,) ) - assert mgr.project_roots == [outer] + assert mgr.project_roots == [outer, inner] + assert skills_dir in mgr.project_skills_dirs diff --git a/tests/core/test_agents.py b/tests/core/test_agents.py index b2a0442..478f0d4 100644 --- a/tests/core/test_agents.py +++ b/tests/core/test_agents.py @@ -20,7 +20,7 @@ class TestAgentProfile: """Test that EXPLORE agent has expected enabled tools.""" enabled_tools = EXPLORE.overrides.get("enabled_tools", []) assert "grep" in enabled_tools - assert "read_file" in enabled_tools + assert "read" in enabled_tools def test_builtin_agents_contains_explore(self) -> None: """Test that BUILTIN_AGENTS includes explore.""" diff --git a/tests/core/test_config_layer.py b/tests/core/test_config_layer.py index f74de98..1fcdf08 100644 --- a/tests/core/test_config_layer.py +++ b/tests/core/test_config_layer.py @@ -13,6 +13,7 @@ from vibe.core.config.layer import ( TrustNotResolvedError, UntrustedLayerError, ) +from vibe.core.config.patch import ConfigPatch class StubLayer(ConfigLayer[BaseModel]): @@ -401,7 +402,7 @@ async def test_get_fingerprint_not_implemented() -> None: async def test_apply_not_implemented() -> None: layer = StubLayer() with pytest.raises(NotImplementedError): - await layer.apply({"op": "set"}) + await layer.apply(ConfigPatch(fingerprint="fp-1")) @pytest.mark.asyncio diff --git a/tests/core/test_config_orchestrator.py b/tests/core/test_config_orchestrator.py index d8a73ad..bd41c4e 100644 --- a/tests/core/test_config_orchestrator.py +++ b/tests/core/test_config_orchestrator.py @@ -7,6 +7,7 @@ import pytest from vibe.core.config.layer import ConfigLayer, RawConfig from vibe.core.config.orchestrator import ConfigOrchestrator +from vibe.core.config.patch import ConfigPatch from vibe.core.config.schema import ConfigSchema, WithReplaceMerge @@ -76,7 +77,7 @@ async def test_origin_of_missing_key_returns_none() -> None: async def test_apply_patch_raises_not_implemented() -> None: orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[]) with pytest.raises(NotImplementedError, match="M2"): - await orch.apply_patch({}) + await orch.apply_patch(ConfigPatch(fingerprint="fp-1")) @pytest.mark.asyncio diff --git a/tests/core/test_config_paths.py b/tests/core/test_config_paths.py index 42f2486..54ac3e5 100644 --- a/tests/core/test_config_paths.py +++ b/tests/core/test_config_paths.py @@ -78,7 +78,7 @@ class TestProjectToolsDirs: mgr = HarnessFilesManager(sources=("user", "project")) assert mgr.project_tools_dirs == [] - def test_finds_tools_dirs_recursively( + def test_does_not_find_nested_tools_dirs( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) @@ -86,19 +86,6 @@ class TestProjectToolsDirs: (tmp_path / ".vibe" / "tools").mkdir(parents=True) (tmp_path / "sub" / ".vibe" / "tools").mkdir(parents=True) mgr = HarnessFilesManager(sources=("user", "project")) - assert mgr.project_tools_dirs == [ - tmp_path / ".vibe" / "tools", - tmp_path / "sub" / ".vibe" / "tools", - ] - - def test_does_not_descend_into_ignored_dirs( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) - (tmp_path / ".vibe" / "tools").mkdir(parents=True) - (tmp_path / ".git" / ".vibe" / "tools").mkdir(parents=True) - mgr = HarnessFilesManager(sources=("user", "project")) assert mgr.project_tools_dirs == [tmp_path / ".vibe" / "tools"] @@ -147,7 +134,7 @@ class TestProjectAgentsDirs: mgr = HarnessFilesManager(sources=("user", "project")) assert mgr.project_agents_dirs == [] - def test_finds_agents_dirs_recursively( + def test_does_not_find_nested_agents_dirs( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) @@ -155,19 +142,6 @@ class TestProjectAgentsDirs: (tmp_path / ".vibe" / "agents").mkdir(parents=True) (tmp_path / "sub" / "deep" / ".vibe" / "agents").mkdir(parents=True) mgr = HarnessFilesManager(sources=("user", "project")) - assert mgr.project_agents_dirs == [ - tmp_path / ".vibe" / "agents", - tmp_path / "sub" / "deep" / ".vibe" / "agents", - ] - - def test_does_not_descend_into_ignored_dirs( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) - (tmp_path / ".vibe" / "agents").mkdir(parents=True) - (tmp_path / "__pycache__" / ".vibe" / "agents").mkdir(parents=True) - mgr = HarnessFilesManager(sources=("user", "project")) assert mgr.project_agents_dirs == [tmp_path / ".vibe" / "agents"] @@ -544,7 +518,7 @@ class TestProjectSkillsDirs: mgr = HarnessFilesManager(sources=("user", "project")) assert mgr.project_skills_dirs == [] - def test_finds_skills_dirs_recursively_in_trusted_folder( + def test_does_not_find_nested_skills_dirs( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) @@ -553,20 +527,6 @@ class TestProjectSkillsDirs: (tmp_path / "sub" / ".agents" / "skills").mkdir(parents=True) (tmp_path / "sub" / "deep" / ".vibe" / "skills").mkdir(parents=True) mgr = HarnessFilesManager(sources=("user", "project")) - assert mgr.project_skills_dirs == [ - tmp_path / ".vibe" / "skills", - tmp_path / "sub" / ".agents" / "skills", - tmp_path / "sub" / "deep" / ".vibe" / "skills", - ] - - def test_does_not_descend_into_ignored_dirs( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) - (tmp_path / ".vibe" / "skills").mkdir(parents=True) - (tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True) - mgr = HarnessFilesManager(sources=("user", "project")) assert mgr.project_skills_dirs == [tmp_path / ".vibe" / "skills"] diff --git a/tests/core/test_config_resolution.py b/tests/core/test_config_resolution.py index 50b534d..24bcd5a 100644 --- a/tests/core/test_config_resolution.py +++ b/tests/core/test_config_resolution.py @@ -309,6 +309,32 @@ class TestSetThinking: assert result["models"][0].get("thinking") is None assert result["models"][1]["thinking"] == "max" + def test_preserves_supports_images_when_materializing_defaults( + self, config_dir: Path + ) -> None: + config_file = config_dir / "config.toml" + data = {"active_model": "mistral-medium-3.5"} + with config_file.open("wb") as f: + tomli_w.dump(data, f) + + cfg = VibeConfig.load() + cfg.set_thinking("low") + + reloaded = VibeConfig.load() + active = reloaded.get_active_model() + assert active.alias == "mistral-medium-3.5" + assert active.thinking == "low" + assert active.supports_images is True + with config_file.open("rb") as f: + result = tomllib.load(f) + active_entry = result["models"][0] + assert active_entry["supports_images"] is True + assert "temperature" not in active_entry + assert "input_price" not in active_entry + assert "output_price" not in active_entry + assert "auto_compact_threshold" not in active_entry + assert "supports_images" not in result["models"][1] + class TestMigrateLeavesFindInBashAllowlist: def test_keeps_find_in_config_file( @@ -659,6 +685,62 @@ class TestMigrateMistralVibeCliLatestDefaults: assert result["models"][0]["output_price"] == 7.5 assert result["models"][0]["thinking"] == "high" + def test_backfills_supports_images_on_existing_mistral_medium_entry( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + config_file = tmp_path / "config.toml" + data = { + "active_model": "mistral-medium-3.5", + "models": [ + { + "name": "mistral-vibe-cli-latest", + "provider": "mistral", + "alias": "mistral-medium-3.5", + "temperature": 1.0, + "input_price": 1.5, + "output_price": 7.5, + "thinking": "high", + } + ], + } + with config_file.open("wb") as f: + tomli_w.dump(data, f) + + reset_harness_files_manager() + init_harness_files_manager("user") + VibeConfig._migrate() + + with config_file.open("rb") as f: + result = tomllib.load(f) + assert result["models"][0]["supports_images"] is True + + def test_preserves_explicit_supports_images_false( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + config_file = tmp_path / "config.toml" + data = { + "models": [ + { + "name": "mistral-vibe-cli-latest", + "provider": "mistral", + "alias": "mistral-medium-3.5", + "supports_images": False, + } + ] + } + with config_file.open("wb") as f: + tomli_w.dump(data, f) + + reset_harness_files_manager() + init_harness_files_manager("user") + VibeConfig._migrate() + + with config_file.open("rb") as f: + result = tomllib.load(f) + assert result["models"][0]["supports_images"] is False + class TestAutoCompactThresholdFallback: def test_model_without_explicit_threshold_inherits_global(self) -> None: @@ -1320,3 +1402,105 @@ class TestIsActiveModelMistral: active_model="llama-local", ) assert cfg.is_active_model_mistral() is False + + +class TestMigrateRenamedTools: + def test_renames_read_file_and_search_replace_keys( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + config_file = tmp_path / "config.toml" + data = { + "tools": { + "read_file": { + "permission": "always", + "allowlist": ["src/**"], + "max_read_bytes": 64000, + }, + "search_replace": { + "allowlist": ["src/**"], + "max_content_size": 100000, + "create_backup": True, + }, + } + } + with config_file.open("wb") as f: + tomli_w.dump(data, f) + + reset_harness_files_manager() + init_harness_files_manager("user") + VibeConfig._migrate() + + with config_file.open("rb") as f: + result = tomllib.load(f) + tools = result["tools"] + assert "read_file" not in tools + assert "search_replace" not in tools + assert tools["read"] == { + "permission": "always", + "allowlist": ["src/**"], + "max_read_bytes": 64000, + } + # Common options carry over; edit-incompatible options are dropped. + assert tools["edit"] == {"allowlist": ["src/**"]} + + def test_prefers_existing_new_key_and_drops_legacy( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + config_file = tmp_path / "config.toml" + data = { + "tools": { + "read_file": {"permission": "always"}, + "read": {"permission": "ask"}, + } + } + with config_file.open("wb") as f: + tomli_w.dump(data, f) + + reset_harness_files_manager() + init_harness_files_manager("user") + VibeConfig._migrate() + + with config_file.open("rb") as f: + result = tomllib.load(f) + assert "read_file" not in result["tools"] + assert result["tools"]["read"] == {"permission": "ask"} + + def test_renames_entries_in_enabled_and_disabled_lists( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + config_file = tmp_path / "config.toml" + data = { + "enabled_tools": ["read_file", "grep"], + "disabled_tools": ["search_replace"], + } + with config_file.open("wb") as f: + tomli_w.dump(data, f) + + reset_harness_files_manager() + init_harness_files_manager("user") + VibeConfig._migrate() + + with config_file.open("rb") as f: + result = tomllib.load(f) + assert result["enabled_tools"] == ["read", "grep"] + assert result["disabled_tools"] == ["edit"] + + def test_noop_when_no_legacy_tool_names( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + config_file = tmp_path / "config.toml" + data = {"tools": {"read": {"permission": "always"}}} + with config_file.open("wb") as f: + tomli_w.dump(data, f) + + reset_harness_files_manager() + init_harness_files_manager("user") + VibeConfig._migrate() + + with config_file.open("rb") as f: + result = tomllib.load(f) + assert result["tools"] == {"read": {"permission": "always"}} diff --git a/tests/core/test_edit_encoding.py b/tests/core/test_edit_encoding.py new file mode 100644 index 0000000..6083ef8 --- /dev/null +++ b/tests/core/test_edit_encoding.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import os +from pathlib import Path +import stat + +import pytest + +from tests.mock.utils import collect_result +from vibe.core.tools.base import BaseToolState, ToolError +from vibe.core.tools.builtins.edit import Edit, EditArgs, EditConfig + + +@pytest.mark.asyncio +async def test_edit_rewrites_with_detected_encoding( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + path = tmp_path / "utf16.txt" + original = "line one café\nline two été\n" + path.write_bytes(original.encode("utf-16")) + + tool = Edit(config_getter=lambda: EditConfig(), state=BaseToolState()) + await collect_result( + tool.run( + EditArgs( + file_path=str(path), + old_string="line one café", + new_string="LINE ONE CAFÉ", + ) + ) + ) + + assert path.read_bytes().startswith(b"\xff\xfe") + assert path.read_text(encoding="utf-16") == "LINE ONE CAFÉ\nline two été\n" + + +@pytest.mark.asyncio +async def test_edit_preserves_file_mode( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + path = tmp_path / "script.sh" + path.write_text("#!/bin/sh\necho old\n") + os.chmod(path, 0o755) + + tool = Edit(config_getter=lambda: EditConfig(), state=BaseToolState()) + await collect_result( + tool.run(EditArgs(file_path=str(path), old_string="old", new_string="new")) + ) + + assert stat.S_IMODE(path.stat().st_mode) == 0o755 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("newline", ["\r\n", "\r", "\n"]) +async def test_edit_preserves_line_endings( + newline: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + path = tmp_path / "f.txt" + original = newline.join(["alpha", "beta", "gamma"]) + path.write_bytes(original.encode("utf-8")) + + tool = Edit(config_getter=lambda: EditConfig(), state=BaseToolState()) + await collect_result( + tool.run(EditArgs(file_path=str(path), old_string="beta", new_string="BETA")) + ) + + assert path.read_bytes() == newline.join(["alpha", "BETA", "gamma"]).encode("utf-8") + + +@pytest.mark.asyncio +async def test_edit_binary_file_raises_tool_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + path = tmp_path / "blob.bin" + # PNG header + some non-text payload. + path.write_bytes(b"\x89PNG\r\n\x1a\n" + bytes(range(256)) * 4) + + tool = Edit(config_getter=lambda: EditConfig(), state=BaseToolState()) + with pytest.raises(ToolError, match="not valid text"): + await collect_result( + tool.run(EditArgs(file_path=str(path), old_string="PNG", new_string="JPG")) + ) diff --git a/tests/core/test_environment_layer.py b/tests/core/test_environment_layer.py new file mode 100644 index 0000000..d43e5f4 --- /dev/null +++ b/tests/core/test_environment_layer.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import os +from unittest.mock import patch + +import pytest + +from vibe.core.config.layers.environment import EnvironmentLayer +from vibe.core.config.vibe_schema import VibeConfigSchema + + +@pytest.mark.asyncio +async def test_reads_env_vars() -> None: + env = { + "MISTRAL_API_KEY": "test-key", + "VIBE_ACTIVE_MODEL": "mistral-large", + "VIBE_VIM_KEYBINDINGS": "true", + "VIBE_ENABLE_TELEMETRY": "0", + "VIBE_UNKNOWN_VAR": "ignored", + "VIBE_SESSION_LOGGING__ENABLED": "false", + "VIBE_SESSION_LOGGING__SESSION_PREFIX": "mysession", + "VIBE_API_TIMEOUT": ".12", + } + with patch.dict(os.environ, env, clear=True): + layer = EnvironmentLayer(schema=VibeConfigSchema) + data = await layer.load() + + assert data.model_dump() == { + "active_model": "mistral-large", + "vim_keybindings": True, + "enable_telemetry": False, + "session_logging": {"enabled": False, "session_prefix": "mysession"}, + "api_timeout": 0.12, + } + + assert layer.name == "environment" + + +@pytest.mark.asyncio +async def test_no_vars_set_returns_empty() -> None: + with patch.dict(os.environ, {}, clear=True): + data = await EnvironmentLayer(schema=VibeConfigSchema).load() + assert data.model_dump() == {} + + +@pytest.mark.asyncio +async def test_always_trusted() -> None: + assert await EnvironmentLayer(schema=VibeConfigSchema).resolve_trust() is True diff --git a/tests/core/test_llm_message_merge.py b/tests/core/test_llm_message_merge.py new file mode 100644 index 0000000..a82e0d0 --- /dev/null +++ b/tests/core/test_llm_message_merge.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from vibe.core.types import ImageAttachment, LLMMessage, Role + + +@pytest.fixture() +def image_a(tmp_path: Path) -> ImageAttachment: + p = tmp_path / "a.png" + p.write_bytes(b"\x89PNG") + return ImageAttachment(path=p, alias="a.png", mime_type="image/png") + + +@pytest.fixture() +def image_b(tmp_path: Path) -> ImageAttachment: + p = tmp_path / "b.png" + p.write_bytes(b"\x89PNG") + return ImageAttachment(path=p, alias="b.png", mime_type="image/png") + + +def _msg(content: str, images: list[ImageAttachment] | None = None) -> LLMMessage: + return LLMMessage(role=Role.assistant, content=content, images=images) + + +def test_merge_prefers_self_images_when_present( + image_a: ImageAttachment, image_b: ImageAttachment +) -> None: + merged = _msg("hi", images=[image_a]) + _msg(" there", images=[image_b]) + assert merged.images == [image_a] + + +def test_merge_falls_back_to_other_when_self_is_none(image_b: ImageAttachment) -> None: + merged = _msg("hi") + _msg(" there", images=[image_b]) + assert merged.images == [image_b] + + +def test_merge_preserves_explicit_empty_self_images_over_other( + image_b: ImageAttachment, +) -> None: + # An explicit `[]` (intentional clearing) must NOT silently inherit + # `other.images`. Truthy/falsy-based merging would have flipped this. + merged = _msg("hi", images=[]) + _msg(" there", images=[image_b]) + assert merged.images == [] + + +def test_merge_yields_none_when_both_sides_are_none() -> None: + merged = _msg("hi") + _msg(" there") + assert merged.images is None diff --git a/tests/core/test_local_config_files.py b/tests/core/test_local_config_files.py new file mode 100644 index 0000000..2e9d317 --- /dev/null +++ b/tests/core/test_local_config_files.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from pathlib import Path + +from vibe.core.paths._local_config_files import LocalConfigDirs, find_local_config_dirs + + +class TestSubdirs: + def test_finds_config_at_root(self, tmp_path: Path) -> None: + (tmp_path / ".vibe" / "tools").mkdir(parents=True) + result = find_local_config_dirs(tmp_path) + assert tmp_path.resolve() / ".vibe" / "tools" in result.tools + + def test_does_not_descend_into_subdirectories(self, tmp_path: Path) -> None: + (tmp_path / "sub" / ".vibe" / "tools").mkdir(parents=True) + (tmp_path / "a" / "b" / ".vibe" / "skills").mkdir(parents=True) + result = find_local_config_dirs(tmp_path) + assert result.tools == () + assert result.skills == () + assert result.agents == () + assert result.config_dirs == () + + def test_finds_agents_skills_at_root(self, tmp_path: Path) -> None: + (tmp_path / ".agents" / "skills").mkdir(parents=True) + result = find_local_config_dirs(tmp_path) + assert tmp_path.resolve() / ".agents" / "skills" in result.skills + + def test_finds_all_config_types_at_root(self, tmp_path: Path) -> None: + (tmp_path / ".vibe" / "tools").mkdir(parents=True) + (tmp_path / ".vibe" / "skills").mkdir(parents=True) + (tmp_path / ".vibe" / "agents").mkdir(parents=True) + (tmp_path / ".agents" / "skills").mkdir(parents=True) + result = find_local_config_dirs(tmp_path) + resolved = tmp_path.resolve() + assert resolved / ".vibe" / "tools" in result.tools + assert resolved / ".vibe" / "skills" in result.skills + assert resolved / ".vibe" / "agents" in result.agents + assert resolved / ".agents" / "skills" in result.skills + + +class TestConfigDirs: + def test_finds_vibe_with_tools(self, tmp_path: Path) -> None: + (tmp_path / ".vibe" / "tools").mkdir(parents=True) + result = find_local_config_dirs(tmp_path) + assert tmp_path.resolve() / ".vibe" in result.config_dirs + + def test_finds_vibe_with_skills(self, tmp_path: Path) -> None: + (tmp_path / ".vibe" / "skills").mkdir(parents=True) + result = find_local_config_dirs(tmp_path) + assert tmp_path.resolve() / ".vibe" in result.config_dirs + + def test_finds_agents_with_skills(self, tmp_path: Path) -> None: + (tmp_path / ".agents" / "skills").mkdir(parents=True) + result = find_local_config_dirs(tmp_path) + assert tmp_path.resolve() / ".agents" in result.config_dirs + + def test_ignores_empty_vibe_dir(self, tmp_path: Path) -> None: + (tmp_path / ".vibe").mkdir() + result = find_local_config_dirs(tmp_path) + assert result.config_dirs == () + + def test_ignores_empty_agents_dir(self, tmp_path: Path) -> None: + (tmp_path / ".agents").mkdir() + result = find_local_config_dirs(tmp_path) + assert result.config_dirs == () + + def test_returns_empty_when_empty(self, tmp_path: Path) -> None: + result = find_local_config_dirs(tmp_path) + assert result.config_dirs == () + + def test_finds_vibe_with_prompts(self, tmp_path: Path) -> None: + (tmp_path / ".vibe" / "prompts").mkdir(parents=True) + result = find_local_config_dirs(tmp_path) + assert tmp_path.resolve() / ".vibe" in result.config_dirs + + def test_finds_vibe_with_config_toml(self, tmp_path: Path) -> None: + (tmp_path / ".vibe").mkdir() + (tmp_path / ".vibe" / "config.toml").write_text("") + result = find_local_config_dirs(tmp_path) + assert tmp_path.resolve() / ".vibe" in result.config_dirs + + def test_finds_vibe_and_agents_at_same_root(self, tmp_path: Path) -> None: + (tmp_path / ".vibe" / "skills").mkdir(parents=True) + (tmp_path / ".agents" / "skills").mkdir(parents=True) + result = find_local_config_dirs(tmp_path) + resolved = tmp_path.resolve() + assert resolved / ".vibe" in result.config_dirs + assert resolved / ".agents" in result.config_dirs + + +class TestLocalConfigDirsOr: + def test_or_concatenates_each_field(self) -> None: + a = LocalConfigDirs( + config_dirs=(Path("/a/.vibe"),), + tools=(Path("/a/.vibe/tools"),), + skills=(Path("/a/.vibe/skills"),), + agents=(Path("/a/.vibe/agents"),), + ) + b = LocalConfigDirs( + config_dirs=(Path("/b/.vibe"),), + tools=(Path("/b/.vibe/tools"),), + skills=(Path("/b/.vibe/skills"),), + agents=(Path("/b/.vibe/agents"),), + ) + merged = a | b + assert merged.config_dirs == (Path("/a/.vibe"), Path("/b/.vibe")) + assert merged.tools == (Path("/a/.vibe/tools"), Path("/b/.vibe/tools")) + assert merged.skills == (Path("/a/.vibe/skills"), Path("/b/.vibe/skills")) + assert merged.agents == (Path("/a/.vibe/agents"), Path("/b/.vibe/agents")) + + def test_or_with_empty_is_identity(self) -> None: + a = LocalConfigDirs(tools=(Path("/a/.vibe/tools"),)) + assert (a | LocalConfigDirs()) == a + assert (LocalConfigDirs() | a) == a diff --git a/tests/core/test_local_config_walk.py b/tests/core/test_local_config_walk.py deleted file mode 100644 index 367aa29..0000000 --- a/tests/core/test_local_config_walk.py +++ /dev/null @@ -1,195 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from vibe.core.paths._local_config_walk import ( - _MAX_DIRS, - WALK_MAX_DEPTH, - ConfigWalkResult, - walk_local_config_dirs, -) - - -class TestWalkTools: - def test_finds_config_at_root(self, tmp_path: Path) -> None: - (tmp_path / ".vibe" / "tools").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - assert tmp_path.resolve() / ".vibe" / "tools" in result.tools - - def test_finds_config_within_depth_limit(self, tmp_path: Path) -> None: - nested = tmp_path - for i in range(WALK_MAX_DEPTH): - nested = nested / f"level{i}" - (nested / ".vibe" / "skills").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - assert nested.resolve() / ".vibe" / "skills" in result.skills - - def test_does_not_find_config_beyond_depth_limit(self, tmp_path: Path) -> None: - nested = tmp_path - for i in range(WALK_MAX_DEPTH + 1): - nested = nested / f"level{i}" - (nested / ".vibe" / "tools").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - assert not result.tools - assert not result.skills - assert not result.agents - - def test_respects_dir_count_limit(self, tmp_path: Path) -> None: - for i in range(_MAX_DIRS + 10): - (tmp_path / f"dir{i:05d}").mkdir() - (tmp_path / "zzz_last" / ".vibe" / "tools").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - assert isinstance(result.tools, tuple) - - def test_skips_ignored_directories(self, tmp_path: Path) -> None: - (tmp_path / "node_modules" / ".vibe" / "tools").mkdir(parents=True) - (tmp_path / ".vibe" / "tools").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - assert result.tools == (tmp_path.resolve() / ".vibe" / "tools",) - - def test_skips_dot_directories(self, tmp_path: Path) -> None: - (tmp_path / ".hidden" / ".vibe" / "tools").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - assert not result.tools - - def test_preserves_alphabetical_ordering(self, tmp_path: Path) -> None: - (tmp_path / "bbb" / ".vibe" / "tools").mkdir(parents=True) - (tmp_path / "aaa" / ".vibe" / "tools").mkdir(parents=True) - (tmp_path / ".vibe" / "tools").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - resolved = tmp_path.resolve() - assert result.tools == ( - resolved / ".vibe" / "tools", - resolved / "aaa" / ".vibe" / "tools", - resolved / "bbb" / ".vibe" / "tools", - ) - - def test_finds_agents_skills(self, tmp_path: Path) -> None: - (tmp_path / ".agents" / "skills").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - assert tmp_path.resolve() / ".agents" / "skills" in result.skills - - def test_finds_all_config_types(self, tmp_path: Path) -> None: - (tmp_path / ".vibe" / "tools").mkdir(parents=True) - (tmp_path / ".vibe" / "skills").mkdir(parents=True) - (tmp_path / ".vibe" / "agents").mkdir(parents=True) - (tmp_path / ".agents" / "skills").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - resolved = tmp_path.resolve() - assert resolved / ".vibe" / "tools" in result.tools - assert resolved / ".vibe" / "skills" in result.skills - assert resolved / ".vibe" / "agents" in result.agents - assert resolved / ".agents" / "skills" in result.skills - - -class TestWalkConfigDirs: - def test_finds_vibe_with_tools(self, tmp_path: Path) -> None: - (tmp_path / ".vibe" / "tools").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - assert tmp_path.resolve() / ".vibe" in result.config_dirs - - def test_finds_vibe_with_skills(self, tmp_path: Path) -> None: - (tmp_path / ".vibe" / "skills").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - assert tmp_path.resolve() / ".vibe" in result.config_dirs - - def test_finds_agents_with_skills(self, tmp_path: Path) -> None: - (tmp_path / ".agents" / "skills").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - assert tmp_path.resolve() / ".agents" in result.config_dirs - - def test_ignores_empty_vibe_dir(self, tmp_path: Path) -> None: - (tmp_path / ".vibe").mkdir() - result = walk_local_config_dirs(tmp_path) - assert result.config_dirs == () - - def test_ignores_empty_agents_dir(self, tmp_path: Path) -> None: - (tmp_path / ".agents").mkdir() - result = walk_local_config_dirs(tmp_path) - assert result.config_dirs == () - - def test_returns_empty_when_empty(self, tmp_path: Path) -> None: - result = walk_local_config_dirs(tmp_path) - assert result.config_dirs == () - - def test_finds_shallow_nested(self, tmp_path: Path) -> None: - (tmp_path / "sub" / ".vibe" / "skills").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - assert tmp_path.resolve() / "sub" / ".vibe" in result.config_dirs - - def test_finds_at_depth_2(self, tmp_path: Path) -> None: - (tmp_path / "a" / "b" / ".agents" / "skills").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - assert tmp_path.resolve() / "a" / "b" / ".agents" in result.config_dirs - - def test_returns_empty_beyond_default_depth(self, tmp_path: Path) -> None: - (tmp_path / "a" / "b" / "c" / "d" / "e" / ".vibe" / "tools").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - assert result.config_dirs == () - - def test_custom_depth(self, tmp_path: Path) -> None: - (tmp_path / "a" / "b" / "c" / "d" / "e" / ".vibe" / "tools").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path, max_depth=5) - assert ( - tmp_path.resolve() / "a" / "b" / "c" / "d" / "e" / ".vibe" - in result.config_dirs - ) - - def test_finds_match_among_many_dirs(self, tmp_path: Path) -> None: - (tmp_path / ".vibe" / "tools").mkdir(parents=True) - for i in range(100): - (tmp_path / f"dir{i}").mkdir() - result = walk_local_config_dirs(tmp_path) - assert tmp_path.resolve() / ".vibe" in result.config_dirs - - def test_skips_ignored_directories(self, tmp_path: Path) -> None: - (tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - assert result.config_dirs == () - - def test_finds_vibe_with_prompts(self, tmp_path: Path) -> None: - (tmp_path / ".vibe" / "prompts").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - assert tmp_path.resolve() / ".vibe" in result.config_dirs - - def test_finds_vibe_with_config_toml(self, tmp_path: Path) -> None: - (tmp_path / ".vibe").mkdir() - (tmp_path / ".vibe" / "config.toml").write_text("") - result = walk_local_config_dirs(tmp_path) - assert tmp_path.resolve() / ".vibe" in result.config_dirs - - def test_finds_multiple_config_dirs(self, tmp_path: Path) -> None: - (tmp_path / ".vibe" / "skills").mkdir(parents=True) - (tmp_path / ".agents" / "skills").mkdir(parents=True) - (tmp_path / "sub" / ".vibe" / "tools").mkdir(parents=True) - result = walk_local_config_dirs(tmp_path) - resolved = tmp_path.resolve() - assert resolved / ".vibe" in result.config_dirs - assert resolved / ".agents" in result.config_dirs - assert resolved / "sub" / ".vibe" in result.config_dirs - - -class TestConfigWalkResultOr: - def test_or_concatenates_each_field(self) -> None: - a = ConfigWalkResult( - config_dirs=(Path("/a/.vibe"),), - tools=(Path("/a/.vibe/tools"),), - skills=(Path("/a/.vibe/skills"),), - agents=(Path("/a/.vibe/agents"),), - ) - b = ConfigWalkResult( - config_dirs=(Path("/b/.vibe"),), - tools=(Path("/b/.vibe/tools"),), - skills=(Path("/b/.vibe/skills"),), - agents=(Path("/b/.vibe/agents"),), - ) - merged = a | b - assert merged.config_dirs == (Path("/a/.vibe"), Path("/b/.vibe")) - assert merged.tools == (Path("/a/.vibe/tools"), Path("/b/.vibe/tools")) - assert merged.skills == (Path("/a/.vibe/skills"), Path("/b/.vibe/skills")) - assert merged.agents == (Path("/a/.vibe/agents"), Path("/b/.vibe/agents")) - - def test_or_with_empty_is_identity(self) -> None: - a = ConfigWalkResult(tools=(Path("/a/.vibe/tools"),)) - assert (a | ConfigWalkResult()) == a - assert (ConfigWalkResult() | a) == a diff --git a/tests/core/test_overrides_layer.py b/tests/core/test_overrides_layer.py index 7ed0b24..1d93ebd 100644 --- a/tests/core/test_overrides_layer.py +++ b/tests/core/test_overrides_layer.py @@ -3,6 +3,7 @@ from __future__ import annotations import pytest from vibe.core.config.layers.overrides import OverridesLayer +from vibe.core.config.patch import ConfigPatch @pytest.mark.asyncio @@ -23,7 +24,7 @@ async def test_always_trusted() -> None: async def test_apply_raises_not_implemented() -> None: layer = OverridesLayer(data={}) with pytest.raises(NotImplementedError, match="M2"): - await layer.apply({"op": "set"}) + await layer.apply(ConfigPatch(fingerprint="fp-1")) @pytest.mark.asyncio diff --git a/tests/core/test_project_config_layer.py b/tests/core/test_project_config_layer.py index c8b74f3..8142334 100644 --- a/tests/core/test_project_config_layer.py +++ b/tests/core/test_project_config_layer.py @@ -6,6 +6,7 @@ import pytest from vibe.core.config.layer import UntrustedLayerError from vibe.core.config.layers.project import ProjectConfigLayer +from vibe.core.config.patch import ConfigPatch from vibe.core.paths._vibe_home import GlobalPath from vibe.core.trusted_folders import trusted_folders_manager @@ -66,7 +67,7 @@ async def test_default_name(tmp_working_directory: Path) -> None: async def test_apply_raises_not_implemented(tmp_working_directory: Path) -> None: layer = ProjectConfigLayer(path=tmp_working_directory) with pytest.raises(NotImplementedError, match="M2"): - await layer.apply({"op": "set"}) + await layer.apply(ConfigPatch(fingerprint="fp-1")) @pytest.mark.asyncio diff --git a/tests/core/test_remote_agent_loop.py b/tests/core/test_remote_agent_loop.py index 1f4ef8a..9cfb888 100644 --- a/tests/core/test_remote_agent_loop.py +++ b/tests/core/test_remote_agent_loop.py @@ -413,7 +413,6 @@ def test_working_task_promoted_to_real_tool_call_does_not_create_duplicate_row() "output": { "path": "/workspace/hello.py", "bytes_written": 22, - "file_existed": False, "content": 'print("Hello, World!")', }, }, @@ -456,7 +455,6 @@ def test_idle_boundary_waits_for_open_tool_results() -> None: "output": { "path": "/workspace/hello_world.js", "bytes_written": 29, - "file_existed": False, "content": "console.log('Hello, World!');", }, }, @@ -731,7 +729,7 @@ def test_working_completed_with_tool_call_id_emits_error_result() -> None: "working", { "title": "Executing write_file", - "content": "Error: File exists. Set overwrite=True.", + "content": "Error: Permission denied.", "toolUIState": { "type": "file", "toolCallId": "call-write-err", diff --git a/tests/core/test_retry.py b/tests/core/test_retry.py index b859789..b677454 100644 --- a/tests/core/test_retry.py +++ b/tests/core/test_retry.py @@ -1,9 +1,15 @@ from __future__ import annotations +from collections.abc import AsyncGenerator + import httpx import pytest -from vibe.core.utils.retry import _is_retryable_http_error +from vibe.core.utils.retry import ( + _is_retryable_http_error, + async_generator_retry, + async_retry, +) def _make_http_status_error(status_code: int) -> httpx.HTTPStatusError: @@ -15,6 +21,10 @@ def _make_http_status_error(status_code: int) -> httpx.HTTPStatusError: ) +def _make_request(url: str = "https://example.com") -> httpx.Request: + return httpx.Request("POST", url) + + class TestIsRetryableHttpError: @pytest.mark.parametrize("code", [408, 409, 425, 429, 500, 502, 503, 504, 529]) def test_retryable_codes(self, code: int) -> None: @@ -24,8 +34,127 @@ class TestIsRetryableHttpError: def test_non_retryable_codes(self, code: int) -> None: assert _is_retryable_http_error(_make_http_status_error(code)) is False + @pytest.mark.parametrize( + "exc", + [ + httpx.ConnectTimeout("connect timed out", request=_make_request()), + httpx.ReadTimeout("read timed out", request=_make_request()), + httpx.WriteTimeout("write timed out", request=_make_request()), + httpx.PoolTimeout("pool timed out", request=_make_request()), + httpx.ConnectError("connection refused", request=_make_request()), + httpx.ReadError("read failed", request=_make_request()), + httpx.WriteError("write failed", request=_make_request()), + httpx.RemoteProtocolError("server disconnected", request=_make_request()), + ], + ) + def test_retryable_network_errors(self, exc: Exception) -> None: + assert _is_retryable_http_error(exc) is True + + def test_non_retryable_request_error(self) -> None: + assert _is_retryable_http_error(httpx.InvalidURL("bad url")) is False + def test_non_http_error_returns_false(self) -> None: assert _is_retryable_http_error(ValueError("not http")) is False def test_generic_exception_returns_false(self) -> None: assert _is_retryable_http_error(RuntimeError("boom")) is False + + +class TestAsyncRetry: + @pytest.mark.asyncio + async def test_retries_network_error_then_succeeds(self) -> None: + attempts = 0 + + @async_retry(tries=3, delay_seconds=0.0, backoff_factor=1.0) + async def call() -> str: + nonlocal attempts + attempts += 1 + if attempts < 2: + raise httpx.ConnectTimeout("timeout", request=_make_request()) + return "ok" + + result = await call() + assert result == "ok" + assert attempts == 2 + + @pytest.mark.asyncio + async def test_does_not_retry_non_retryable(self) -> None: + attempts = 0 + + @async_retry(tries=3, delay_seconds=0.0, backoff_factor=1.0) + async def call() -> str: + nonlocal attempts + attempts += 1 + raise ValueError("nope") + + with pytest.raises(ValueError): + await call() + assert attempts == 1 + + @pytest.mark.asyncio + async def test_exhausts_retries(self) -> None: + attempts = 0 + + @async_retry(tries=3, delay_seconds=0.0, backoff_factor=1.0) + async def call() -> str: + nonlocal attempts + attempts += 1 + raise httpx.ReadTimeout("timeout", request=_make_request()) + + with pytest.raises(httpx.ReadTimeout): + await call() + assert attempts == 3 + + +class TestAsyncGeneratorRetry: + @pytest.mark.asyncio + async def test_retries_before_first_yield(self) -> None: + attempts = 0 + + @async_generator_retry(tries=3, delay_seconds=0.0, backoff_factor=1.0) + async def gen() -> AsyncGenerator[int]: + nonlocal attempts + attempts += 1 + if attempts < 2: + raise httpx.ConnectError("connect failed", request=_make_request()) + yield 1 + yield 2 + + items = [item async for item in gen()] + assert items == [1, 2] + assert attempts == 2 + + @pytest.mark.asyncio + async def test_does_not_retry_after_first_yield(self) -> None: + attempts = 0 + + @async_generator_retry(tries=3, delay_seconds=0.0, backoff_factor=1.0) + async def gen() -> AsyncGenerator[int]: + nonlocal attempts + attempts += 1 + yield 1 + raise httpx.ReadError("midstream", request=_make_request()) + + items: list[int] = [] + with pytest.raises(httpx.ReadError): + async for item in gen(): + items.append(item) + + assert items == [1] + assert attempts == 1 + + @pytest.mark.asyncio + async def test_does_not_retry_non_retryable_before_yield(self) -> None: + attempts = 0 + + @async_generator_retry(tries=3, delay_seconds=0.0, backoff_factor=1.0) + async def gen() -> AsyncGenerator[int]: + nonlocal attempts + attempts += 1 + raise ValueError("nope") + yield 0 # pragma: no cover + + with pytest.raises(ValueError): + async for _ in gen(): + pass + assert attempts == 1 diff --git a/tests/core/test_rewind_integration.py b/tests/core/test_rewind_integration.py index 0b84adc..94a3677 100644 --- a/tests/core/test_rewind_integration.py +++ b/tests/core/test_rewind_integration.py @@ -25,23 +25,24 @@ async def _act_and_collect(agent_loop, prompt: str) -> list[BaseEvent]: def _write_file_tool_call( - path: str, content: str, *, call_id: str = "call_1", overwrite: bool = False + path: str, content: str, *, call_id: str = "call_1" ) -> ToolCall: - args = json.dumps({"path": path, "content": content, "overwrite": overwrite}) + args = json.dumps({"path": path, "content": content}) return ToolCall( id=call_id, index=0, function=FunctionCall(name="write_file", arguments=args) ) -def _search_replace_tool_call( - file_path: str, search: str, replace: str, *, call_id: str = "call_1" +def _edit_tool_call( + file_path: str, old_string: str, new_string: str, *, call_id: str = "call_1" ) -> ToolCall: - content = f"<<<<<<< SEARCH\n{search}\n=======\n{replace}\n>>>>>>> REPLACE" - args = json.dumps({"file_path": file_path, "content": content}) + args = json.dumps({ + "file_path": file_path, + "old_string": old_string, + "new_string": new_string, + }) return ToolCall( - id=call_id, - index=0, - function=FunctionCall(name="search_replace", arguments=args), + id=call_id, index=0, function=FunctionCall(name="edit", arguments=args) ) @@ -54,10 +55,10 @@ def _bash_tool_call(command: str, *, call_id: str = "call_1") -> ToolCall: def _make_agent_loop(backend: FakeBackend): config = build_test_vibe_config( - enabled_tools=["write_file", "search_replace", "bash"], + enabled_tools=["write_file", "edit", "bash"], tools={ "write_file": {"permission": "always"}, - "search_replace": {"permission": "always"}, + "edit": {"permission": "always"}, "bash": {"permission": "always"}, }, system_prompt_id="tests", @@ -98,10 +99,10 @@ class TestRewindIntegration: assert not target.exists() - async def test_search_replace_rewind_restores_previous_version( + async def test_edit_rewind_restores_previous_version( self, tmp_working_directory: Path ) -> None: - """Edit a pre-existing file with search_replace, rewind restores original.""" + """Edit a pre-existing file with edit, rewind restores original.""" target = tmp_working_directory / "config.yaml" target.write_text("key: original\n", encoding="utf-8") @@ -110,9 +111,7 @@ class TestRewindIntegration: mock_llm_chunk( content="Updating config.", tool_calls=[ - _search_replace_tool_call( - str(target), "key: original", "key: modified" - ) + _edit_tool_call(str(target), "key: original", "key: modified") ], ) ], @@ -129,11 +128,11 @@ class TestRewindIntegration: assert target.read_text() == "key: original\n" - async def test_write_then_search_replace_rewind_to_middle( + async def test_write_then_edit_rewind_to_middle( self, tmp_working_directory: Path ) -> None: """Turn 1 creates a file with write_file, turn 2 patches it with - search_replace. Rewind to turn 2 restores the turn 1 version. + edit. Rewind to turn 2 restores the turn 1 version. """ target = tmp_working_directory / "app.py" @@ -148,12 +147,12 @@ class TestRewindIntegration: ) ], [mock_llm_chunk(content="Created.")], - # Turn 2: patch with search_replace + # Turn 2: patch with edit [ mock_llm_chunk( content="Updating.", tool_calls=[ - _search_replace_tool_call( + _edit_tool_call( str(target), " pass", ' print("hello")', @@ -222,9 +221,7 @@ class TestRewindIntegration: mock_llm_chunk( content="v2.", tool_calls=[ - _write_file_tool_call( - str(target), "v2", call_id="call_2", overwrite=True - ) + _edit_tool_call(str(target), "v1", "v2", call_id="call_2") ], ) ], @@ -234,9 +231,7 @@ class TestRewindIntegration: mock_llm_chunk( content="v2bis.", tool_calls=[ - _write_file_tool_call( - str(target), "v2bis", call_id="call_3", overwrite=True - ) + _edit_tool_call(str(target), "v1", "v2bis", call_id="call_3") ], ) ], diff --git a/tests/core/test_search_replace_encoding.py b/tests/core/test_search_replace_encoding.py deleted file mode 100644 index 82d8fb6..0000000 --- a/tests/core/test_search_replace_encoding.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from tests.mock.utils import collect_result -from vibe.core.tools.base import BaseToolState -from vibe.core.tools.builtins.search_replace import ( - SearchReplace, - SearchReplaceArgs, - SearchReplaceConfig, -) - - -@pytest.mark.asyncio -async def test_search_replace_rewrites_with_detected_encoding( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.chdir(tmp_path) - path = tmp_path / "utf16.txt" - original = "line one café\nline two été\n" - path.write_bytes(original.encode("utf-16")) - - tool = SearchReplace( - config_getter=lambda: SearchReplaceConfig(), state=BaseToolState() - ) - patch = "<<<<<<< SEARCH\nline one café\n=======\nLINE ONE CAFÉ\n>>>>>>> REPLACE" - await collect_result( - tool.run(SearchReplaceArgs(file_path=str(path), content=patch)) - ) - - assert path.read_bytes().startswith(b"\xff\xfe") - assert path.read_text(encoding="utf-16") == "LINE ONE CAFÉ\nline two été\n" diff --git a/tests/core/test_telemetry_send.py b/tests/core/test_telemetry_send.py index 4141d5d..681dabb 100644 --- a/tests/core/test_telemetry_send.py +++ b/tests/core/test_telemetry_send.py @@ -28,9 +28,7 @@ def _make_resolved_tool_call( tool_name: str, args_dict: dict[str, Any] ) -> ResolvedToolCall: if tool_name == "write_file": - validated = WriteFileArgs( - path="foo.txt", content="x", overwrite=args_dict.get("overwrite", False) - ) + validated = WriteFileArgs(path="foo.txt", content="x") cls: type[BaseTool] = WriteFile else: validated = FakeToolArgs() @@ -169,12 +167,12 @@ class TestTelemetryClient: assert telemetry_events[0]["properties"]["message_id"] == "msg-123" - def test_send_tool_call_finished_nb_files_created_write_file_new( + def test_send_tool_call_finished_nb_files_created_write_file( self, telemetry_events: list[dict[str, Any]] ) -> None: config = build_test_vibe_config(enable_telemetry=True) client = TelemetryClient(config_getter=lambda: config) - tool_call = _make_resolved_tool_call("write_file", {"overwrite": False}) + tool_call = _make_resolved_tool_call("write_file", {}) client.send_tool_call_finished( tool_call=tool_call, @@ -182,31 +180,12 @@ class TestTelemetryClient: decision=None, agent_profile_name="default", model="mistral-large", - result={"file_existed": False}, + result={}, ) assert telemetry_events[0]["properties"]["nb_files_created"] == 1 assert telemetry_events[0]["properties"]["nb_files_modified"] == 0 - def test_send_tool_call_finished_nb_files_modified_write_file_overwrite( - self, telemetry_events: list[dict[str, Any]] - ) -> None: - config = build_test_vibe_config(enable_telemetry=True) - client = TelemetryClient(config_getter=lambda: config) - tool_call = _make_resolved_tool_call("write_file", {"overwrite": True}) - - client.send_tool_call_finished( - tool_call=tool_call, - status="success", - decision=None, - agent_profile_name="default", - model="mistral-large", - result={"file_existed": True}, - ) - - assert telemetry_events[0]["properties"]["nb_files_created"] == 0 - assert telemetry_events[0]["properties"]["nb_files_modified"] == 1 - def test_send_tool_call_finished_decision_none( self, telemetry_events: list[dict[str, Any]] ) -> None: diff --git a/tests/core/test_trusted_folders.py b/tests/core/test_trusted_folders.py index cfba99d..d4e82bf 100644 --- a/tests/core/test_trusted_folders.py +++ b/tests/core/test_trusted_folders.py @@ -10,6 +10,8 @@ import tomli_w from vibe.core.paths import AGENTS_MD_FILENAME, TRUSTED_FOLDERS_FILE from vibe.core.trusted_folders import ( TrustedFoldersManager, + find_git_repo_ancestor, + find_repo_trustable_files_for_cwd, find_trustable_files, has_agents_md_file, ) @@ -291,15 +293,46 @@ class TestFindTrustRoot: # child should find parent (closest), not tmp_path assert manager.find_trust_root(child) == parent.resolve() - def test_ignores_untrusted_ancestors(self, tmp_path: Path) -> None: + def test_returns_none_when_closer_ancestor_is_untrusted( + self, tmp_path: Path + ) -> None: parent = tmp_path / "parent" child = parent / "child" child.mkdir(parents=True) manager = TrustedFoldersManager() manager.add_untrusted(parent) manager.add_trusted(tmp_path) - # find_trust_root skips untrusted, finds tmp_path - assert manager.find_trust_root(child) == tmp_path.resolve() + # The closer untrusted ancestor blocks the higher trusted one. + assert manager.find_trust_root(child) is None + + def test_returns_none_when_path_itself_is_untrusted(self, tmp_path: Path) -> None: + manager = TrustedFoldersManager() + manager.add_untrusted(tmp_path) + assert manager.find_trust_root(tmp_path) is None + + +class TestIsExplicitlyUntrusted: + def test_returns_true_for_path_in_untrusted_list(self, tmp_path: Path) -> None: + manager = TrustedFoldersManager() + manager.add_untrusted(tmp_path) + assert manager.is_explicitly_untrusted(tmp_path) is True + + def test_returns_false_for_unknown_path(self, tmp_path: Path) -> None: + manager = TrustedFoldersManager() + assert manager.is_explicitly_untrusted(tmp_path) is False + + def test_does_not_walk_ancestors(self, tmp_path: Path) -> None: + child = tmp_path / "child" + child.mkdir() + manager = TrustedFoldersManager() + manager.add_untrusted(tmp_path) + # parent is untrusted, but child itself is not in the list + assert manager.is_explicitly_untrusted(child) is False + + def test_returns_false_for_trusted_path(self, tmp_path: Path) -> None: + manager = TrustedFoldersManager() + manager.add_trusted(tmp_path) + assert manager.is_explicitly_untrusted(tmp_path) is False class TestHasAgentsMdFile: @@ -351,37 +384,132 @@ class TestFindTrustableFiles: (tmp_path / "other.txt").write_text("", encoding="utf-8") assert find_trustable_files(tmp_path) == [] - def test_detects_vibe_config_in_subfolder(self, tmp_path: Path) -> None: + def test_ignores_vibe_config_in_subfolder(self, tmp_path: Path) -> None: (tmp_path / "sub" / ".vibe" / "skills").mkdir(parents=True) - result = find_trustable_files(tmp_path) - assert "sub/.vibe/" in result + assert find_trustable_files(tmp_path) == [] - def test_detects_agents_skills_in_subfolder(self, tmp_path: Path) -> None: + def test_ignores_agents_skills_in_subfolder(self, tmp_path: Path) -> None: (tmp_path / "deep" / "nested" / ".agents" / "skills").mkdir(parents=True) - result = find_trustable_files(tmp_path) - assert "deep/nested/.agents/" in result + assert find_trustable_files(tmp_path) == [] - def test_returns_empty_when_config_only_inside_ignored_dir( - self, tmp_path: Path - ) -> None: + def test_returns_empty_when_config_only_in_subfolder(self, tmp_path: Path) -> None: (tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True) assert find_trustable_files(tmp_path) == [] - def test_detects_nested_vibe_dir(self, tmp_path: Path) -> None: - (tmp_path / "pkg" / ".vibe" / "tools").mkdir(parents=True) - result = find_trustable_files(tmp_path) - assert "pkg/.vibe/" in result - - def test_detects_multiple_files(self, tmp_path: Path) -> None: + def test_detects_multiple_files_at_root(self, tmp_path: Path) -> None: (tmp_path / ".vibe" / "skills").mkdir(parents=True) + (tmp_path / ".agents" / "skills").mkdir(parents=True) (tmp_path / "AGENTS.md").write_text("# Agent", encoding="utf-8") - (tmp_path / "sub" / ".agents" / "skills").mkdir(parents=True) result = find_trustable_files(tmp_path) assert ".vibe/" in result + assert ".agents/" in result assert "AGENTS.md" in result - assert "sub/.agents/" in result def test_no_duplicates_for_root_vibe_dir(self, tmp_path: Path) -> None: (tmp_path / ".vibe" / "tools").mkdir(parents=True) result = find_trustable_files(tmp_path) assert result.count(".vibe/") == 1 + + +def _make_git_repo(path: Path) -> None: + git_dir = path / ".git" + git_dir.mkdir() + (git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8") + + +class TestFindRepoTrustableFilesForCwd: + def test_returns_empty_when_repo_root_is_none(self, tmp_path: Path) -> None: + assert find_repo_trustable_files_for_cwd(tmp_path, None) == [] + + def test_returns_empty_when_repo_root_is_not_ancestor(self, tmp_path: Path) -> None: + cwd = tmp_path / "cwd" + cwd.mkdir() + repo = tmp_path / "repo" + repo.mkdir() + _make_git_repo(repo) + assert find_repo_trustable_files_for_cwd(cwd, repo) == [] + + def test_includes_root_trustable_files(self, tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _make_git_repo(repo) + (repo / ".vibe" / "skills").mkdir(parents=True) + (repo / ".agents" / "skills").mkdir(parents=True) + (repo / "AGENTS.md").write_text("# Agent", encoding="utf-8") + cwd = repo / "src" / "pkg" + cwd.mkdir(parents=True) + + assert find_repo_trustable_files_for_cwd(cwd, repo) == [ + ".agents/", + ".vibe/", + "AGENTS.md", + ] + + def test_includes_agents_md_between_cwd_and_repo_root(self, tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _make_git_repo(repo) + cwd = repo / "src" / "pkg" / "deep" + cwd.mkdir(parents=True) + (repo / "src" / "AGENTS.md").write_text("# Source", encoding="utf-8") + (repo / "src" / "pkg" / "AGENTS.md").write_text("# Package", encoding="utf-8") + (cwd / "AGENTS.md").write_text("# Cwd", encoding="utf-8") + + assert find_repo_trustable_files_for_cwd(cwd, repo) == [ + "src/AGENTS.md", + "src/pkg/AGENTS.md", + ] + + +class TestFindGitRepoAncestor: + def test_returns_path_when_directly_contains_git(self, tmp_path: Path) -> None: + _make_git_repo(tmp_path) + assert find_git_repo_ancestor(tmp_path) == tmp_path.resolve() + + def test_ignores_git_file_pointer(self, tmp_path: Path) -> None: + (tmp_path / ".git").write_text("gitdir: /elsewhere", encoding="utf-8") + # Not a directory, so not treated as a repo root. + assert find_git_repo_ancestor(tmp_path) is None + + def test_ignores_empty_git_directory(self, tmp_path: Path) -> None: + (tmp_path / ".git").mkdir() + # Missing HEAD, so not a real repo. + assert find_git_repo_ancestor(tmp_path) is None + + def test_returns_closest_ancestor_with_git(self, tmp_path: Path) -> None: + _make_git_repo(tmp_path) + nested = tmp_path / "a" / "b" / "c" + nested.mkdir(parents=True) + assert find_git_repo_ancestor(nested) == tmp_path.resolve() + + def test_returns_innermost_when_multiple_git_repos(self, tmp_path: Path) -> None: + _make_git_repo(tmp_path) + inner = tmp_path / "sub" + inner.mkdir() + _make_git_repo(inner) + child = inner / "deep" + child.mkdir() + assert find_git_repo_ancestor(child) == inner.resolve() + + def test_returns_none_when_no_git_anywhere( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(Path, "home", classmethod(lambda _cls: tmp_path.parent)) + assert find_git_repo_ancestor(tmp_path / "a") is None + + def test_excludes_home_directory( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + home = tmp_path / "home" + home.mkdir() + _make_git_repo(home) + monkeypatch.setattr(Path, "home", classmethod(lambda _cls: home)) + sub = home / "project" + sub.mkdir() + assert find_git_repo_ancestor(sub) is None + + def test_terminates_at_filesystem_root_without_git( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.setattr(Path, "home", classmethod(lambda _cls: tmp_path / "nope")) + assert find_git_repo_ancestor(tmp_path) is None diff --git a/tests/core/test_user_config_layer.py b/tests/core/test_user_config_layer.py index 755f9f4..b9816b7 100644 --- a/tests/core/test_user_config_layer.py +++ b/tests/core/test_user_config_layer.py @@ -6,6 +6,7 @@ import pytest from vibe.core.config.layer import LayerImplementationError from vibe.core.config.layers.user import UserConfigLayer +from vibe.core.config.patch import ConfigPatch @pytest.mark.asyncio @@ -43,7 +44,7 @@ async def test_apply_raises_not_implemented(tmp_working_directory: Path) -> None path = tmp_working_directory / "config.toml" layer = UserConfigLayer(path=path, name="user-toml") with pytest.raises(NotImplementedError, match="M2"): - await layer.apply({"op": "set"}) + await layer.apply(ConfigPatch(fingerprint="fp-1")) @pytest.mark.asyncio diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index 450006c..973d7a2 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -1,12 +1,21 @@ from __future__ import annotations +import asyncio from pathlib import Path import pytest from vibe.core.utils import compact_complete_display, get_server_url_from_api_base import vibe.core.utils.io as io_utils -from vibe.core.utils.io import decode_safe, read_safe, read_safe_async +from vibe.core.utils.io import ( + _FILE_WRITE_LOCKS, + decode_safe, + file_write_lock, + read_lines_safe, + read_lines_safe_async, + read_safe, + read_safe_async, +) @pytest.mark.parametrize( @@ -201,3 +210,127 @@ class TestReadSafeAsync: assert (await read_safe_async(f, raise_on_error=False)).text == "maf�\n" with pytest.raises(UnicodeDecodeError): await read_safe_async(f, raise_on_error=True) + + +class TestReadLinesSafe: + def test_small_file_fully_read(self, tmp_path: Path) -> None: + f = tmp_path / "f.txt" + f.write_text("a\nb\nc\n", encoding="utf-8") + got = read_lines_safe(f, limit=100, max_bytes=1024) + assert got.lines == ["a", "b", "c"] + assert got.total_lines == 3 + assert got.was_truncated is False + + def test_no_trailing_newline(self, tmp_path: Path) -> None: + f = tmp_path / "f.txt" + f.write_text("a\nb", encoding="utf-8") + got = read_lines_safe(f, limit=100, max_bytes=1024) + assert got.lines == ["a", "b"] + assert got.total_lines == 2 + assert got.was_truncated is False + + def test_truncates_at_limit(self, tmp_path: Path) -> None: + f = tmp_path / "f.txt" + f.write_text("".join(f"line {i}\n" for i in range(1, 101)), encoding="utf-8") + got = read_lines_safe(f, limit=10, max_bytes=1024) + assert got.lines == [f"line {i}" for i in range(1, 11)] + assert got.total_lines is None + assert got.was_truncated is True + + def test_offset_skips_leading_lines(self, tmp_path: Path) -> None: + f = tmp_path / "f.txt" + f.write_text("".join(f"line {i}\n" for i in range(1, 11)), encoding="utf-8") + got = read_lines_safe(f, start_line=3, limit=2, max_bytes=1024) + assert got.lines == ["line 3", "line 4"] + assert got.was_truncated is True + + def test_offset_past_eof_reports_total(self, tmp_path: Path) -> None: + f = tmp_path / "f.txt" + f.write_text("a\nb\n", encoding="utf-8") + got = read_lines_safe(f, start_line=100, limit=10, max_bytes=1024) + assert got.lines == [] + assert got.total_lines == 2 + assert got.was_truncated is False + + def test_does_not_load_whole_file(self, tmp_path: Path) -> None: + f = tmp_path / "big.txt" + f.write_text("".join(f"line {i}\n" for i in range(1_000_000)), encoding="utf-8") + got = read_lines_safe(f, limit=5, max_bytes=1024) + assert got.lines == [f"line {i}" for i in range(5)] + assert got.total_lines is None + assert got.was_truncated is True + + def test_oversized_single_line_returns_partial(self, tmp_path: Path) -> None: + f = tmp_path / "f.txt" + f.write_text("x" * 5000 + "\n", encoding="utf-8") + got = read_lines_safe(f, limit=10, max_bytes=1024) + assert got.lines == ["x" * 1024] + assert got.was_truncated is True + + def test_cumulative_byte_budget_truncates(self, tmp_path: Path) -> None: + f = tmp_path / "f.txt" + f.write_text("".join("x" * 200 + "\n" for _ in range(50)), encoding="utf-8") + got = read_lines_safe(f, limit=50, max_bytes=1024) + assert 0 < len(got.lines) < 50 + assert got.total_lines is None + assert got.was_truncated is True + + def test_oversized_unselected_line_is_skipped(self, tmp_path: Path) -> None: + f = tmp_path / "f.txt" + f.write_text("x" * 5000 + "\nkept\n", encoding="utf-8") + got = read_lines_safe(f, start_line=2, limit=10, max_bytes=1024) + assert got.lines == ["kept"] + + @pytest.mark.parametrize("encoding", ["utf-16-le", "utf-16-be", "utf-16"]) + def test_utf16_is_decoded(self, tmp_path: Path, encoding: str) -> None: + f = tmp_path / "u16.txt" + f.write_bytes("héllo\nwörld\n".encode(encoding)) + got = read_lines_safe(f, limit=10, max_bytes=4096) + assert got.lines[-1] == "wörld" + # A leading BOM may remain as U+FEFF on the first line. + assert got.lines[0].endswith("héllo") + + @pytest.mark.asyncio + async def test_async_matches_sync(self, tmp_path: Path) -> None: + f = tmp_path / "f.txt" + f.write_text("a\nb\nc\n", encoding="utf-8") + got = await read_lines_safe_async(f, limit=2, max_bytes=1024) + assert got.lines == ["a", "b"] + assert got.was_truncated is True + + +class TestFileWriteLock: + @pytest.mark.asyncio + async def test_same_lock_for_different_path_spellings( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + path = tmp_path / "f.txt" + path.touch() + _FILE_WRITE_LOCKS.clear() + + order: list[str] = [] + held = asyncio.Event() + release = asyncio.Event() + + async def first() -> None: + async with file_write_lock(path): + order.append("first-acquired") + held.set() + await release.wait() + order.append("first-released") + + async def second() -> None: + await held.wait() + # Same file, different spelling — must contend on the same lock. + async with file_write_lock(Path("f.txt")): + order.append("second-acquired") + + t1 = asyncio.create_task(first()) + t2 = asyncio.create_task(second()) + await held.wait() + await asyncio.sleep(0) + assert order == ["first-acquired"] + release.set() + await asyncio.gather(t1, t2) + assert order == ["first-acquired", "first-released", "second-acquired"] diff --git a/tests/core/test_vibe_config_schema.py b/tests/core/test_vibe_config_schema.py new file mode 100644 index 0000000..73fd67b --- /dev/null +++ b/tests/core/test_vibe_config_schema.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from vibe.core.config._settings import VibeConfig +from vibe.core.config.vibe_schema import VibeConfigSchema + + +def test_vibe_config_schema_covers_all_vibe_config_fields() -> None: + legacy_fields = set(VibeConfig.model_fields.keys()) + schema_fields = set(VibeConfigSchema.model_fields.keys()) + missing = legacy_fields - schema_fields + assert not missing, ( + f"VibeConfigSchema is missing {len(missing)} field(s) that exist in VibeConfig: " + f"{sorted(missing)}. " + f"When you add a new field to VibeConfig, also add it to VibeConfigSchema " + f"(vibe/core/config/vibe_schema.py) with the appropriate merge annotation." + ) + + +@pytest.mark.asyncio +async def test_full_toml_to_vibe_config_schema(tmp_path: Path) -> None: + toml_path = tmp_path / "config.toml" + toml_path.write_text( + """\ +vim_keybindings = true +api_timeout = 300.0 +active_model = "codestral" +disabled_tools = ["bash"] +default_agent = "plan" +enabled_skills = ["search"] +enable_otel = true + +[[models]] +alias = "codestral" +name = "codestral-latest" +provider = "mistral" +""" + ) + + from vibe.core.config.layers.user import UserConfigLayer + from vibe.core.config.orchestrator import ConfigOrchestrator + + class VibeConfig(VibeConfigSchema): + pass + + layer = UserConfigLayer(path=toml_path, name="user-toml") + orchestrator = await ConfigOrchestrator[VibeConfig].create( + schema=VibeConfig, layers=[layer] + ) + config = orchestrator.config + + assert config.vim_keybindings is True + assert config.api_timeout == 300.0 + assert config.active_model == "codestral" + assert config.models[0].alias == "codestral" + assert "bash" in config.disabled_tools + assert config.default_agent == "plan" + assert "search" in config.enabled_skills + assert config.enable_otel is True diff --git a/tests/core/tools/builtins/test_edit.py b/tests/core/tools/builtins/test_edit.py new file mode 100644 index 0000000..02d3237 --- /dev/null +++ b/tests/core/tools/builtins/test_edit.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from tests.mock.utils import collect_result +from vibe.core.tools.base import BaseToolState, ToolError +from vibe.core.tools.builtins.edit import Edit, EditArgs, EditConfig, EditResult +from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay +from vibe.core.types import ToolResultEvent + + +def _make_edit() -> Edit: + return Edit(config_getter=lambda: EditConfig(), state=BaseToolState()) + + +def _write(tmp_path: Path, name: str, content: str) -> Path: + p = tmp_path / name + p.write_text(content) + return p + + +@pytest.mark.asyncio +async def test_exact_match_replaces( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + _write(tmp_path, "f.txt", "hello world\n") + edit = _make_edit() + + result = await collect_result( + edit.run(EditArgs(file_path="f.txt", old_string="hello", new_string="goodbye")) + ) + + assert (tmp_path / "f.txt").read_text() == "goodbye world\n" + assert result.file == str(tmp_path / "f.txt") + assert result.message == "The file has been updated successfully." + + +@pytest.mark.asyncio +async def test_replace_all(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + _write(tmp_path, "f.txt", "aaa bbb aaa\n") + edit = _make_edit() + + result = await collect_result( + edit.run( + EditArgs( + file_path="f.txt", old_string="aaa", new_string="ccc", replace_all=True + ) + ) + ) + + assert (tmp_path / "f.txt").read_text() == "ccc bbb ccc\n" + assert result.message == ( + "The file has been updated. All occurrences were successfully replaced" + ) + + +@pytest.mark.asyncio +async def test_not_found_raises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + _write(tmp_path, "f.txt", "hello world\n") + edit = _make_edit() + + with pytest.raises(ToolError, match="String to replace not found in file"): + await collect_result( + edit.run(EditArgs(file_path="f.txt", old_string="missing", new_string="x")) + ) + + +@pytest.mark.asyncio +async def test_multiple_matches_without_replace_all_raises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + _write(tmp_path, "f.txt", "aaa bbb aaa\n") + edit = _make_edit() + + with pytest.raises(ToolError, match="Found 2 matches"): + await collect_result( + edit.run(EditArgs(file_path="f.txt", old_string="aaa", new_string="ccc")) + ) + + +@pytest.mark.asyncio +async def test_old_equals_new_raises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + _write(tmp_path, "f.txt", "hello\n") + edit = _make_edit() + + with pytest.raises(ToolError, match="No changes to make"): + await collect_result( + edit.run( + EditArgs(file_path="f.txt", old_string="hello", new_string="hello") + ) + ) + + +@pytest.mark.asyncio +async def test_empty_old_string_raises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + _write(tmp_path, "f.txt", "hello\n") + edit = _make_edit() + + with pytest.raises(ToolError, match="old_string cannot be empty"): + await collect_result( + edit.run(EditArgs(file_path="f.txt", old_string="", new_string="x")) + ) + + +@pytest.mark.asyncio +async def test_file_not_found_raises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + edit = _make_edit() + + with pytest.raises(ToolError, match="File does not exist"): + await collect_result( + edit.run( + EditArgs( + file_path="/nonexistent/file.py", old_string="x", new_string="y" + ) + ) + ) + + +@pytest.mark.asyncio +async def test_empty_file_path_raises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + edit = _make_edit() + + with pytest.raises(ToolError, match="File path cannot be empty"): + await collect_result( + edit.run(EditArgs(file_path="", old_string="x", new_string="y")) + ) + + +@pytest.mark.asyncio +async def test_deletion_removes_exact_string( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + _write(tmp_path, "f.txt", "line1\nline2\nline3\n") + edit = _make_edit() + + await collect_result( + edit.run(EditArgs(file_path="f.txt", old_string="line2\n", new_string="")) + ) + + assert (tmp_path / "f.txt").read_text() == "line1\nline3\n" + + +@pytest.mark.asyncio +async def test_parallel_edits_same_file_all_land( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + _write(tmp_path, "f.txt", "A\nB\nC\nD\n") + edit = _make_edit() + + await asyncio.gather( + collect_result( + edit.run(EditArgs(file_path="f.txt", old_string="A", new_string="A1")) + ), + collect_result( + edit.run(EditArgs(file_path="f.txt", old_string="B", new_string="B1")) + ), + collect_result( + edit.run(EditArgs(file_path="f.txt", old_string="C", new_string="C1")) + ), + collect_result( + edit.run(EditArgs(file_path="f.txt", old_string="D", new_string="D1")) + ), + ) + + assert (tmp_path / "f.txt").read_text() == "A1\nB1\nC1\nD1\n" + + +@pytest.mark.asyncio +async def test_relative_path_resolved_from_cwd( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "sub").mkdir(exist_ok=True) + _write(tmp_path / "sub", "f.txt", "old") + edit = _make_edit() + + result = await collect_result( + edit.run(EditArgs(file_path="sub/f.txt", old_string="old", new_string="new")) + ) + + assert (tmp_path / "sub" / "f.txt").read_text() == "new" + assert result.file == str(tmp_path / "sub" / "f.txt") + + +def test_format_call_display() -> None: + args = EditArgs(file_path="/abs/foo.py", old_string="old", new_string="new") + display = Edit.format_call_display(args) + + assert isinstance(display, ToolCallDisplay) + assert display.summary == "Editing foo.py" + + +def test_get_result_display() -> None: + result = EditResult( + file="/path/to/foo.py", + message="The file has been updated successfully.", + old_string="old", + new_string="new", + ) + event = ToolResultEvent( + tool_call_id="test", tool_name="edit", tool_class=None, result=result + ) + display = Edit.get_result_display(event) + + assert isinstance(display, ToolResultDisplay) + assert display.success is True + assert "foo.py" in display.message diff --git a/tests/core/tools/builtins/test_read.py b/tests/core/tools/builtins/test_read.py new file mode 100644 index 0000000..a3bcd12 --- /dev/null +++ b/tests/core/tools/builtins/test_read.py @@ -0,0 +1,355 @@ +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from tests.mock.utils import collect_result +from vibe.core.config.harness_files import ( + init_harness_files_manager, + reset_harness_files_manager, +) +from vibe.core.tools.base import ToolError +from vibe.core.tools.builtins.read import ( + DEFAULT_LINE_LIMIT, + MAX_BYTES, + Read, + ReadArgs, + ReadConfig, + ReadResult, + ReadState, + _add_line_numbers, +) +from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay +from vibe.core.trusted_folders import trusted_folders_manager +from vibe.core.types import ToolResultEvent +from vibe.core.utils import VIBE_WARNING_TAG + + +def _make_read() -> Read: + return Read(config_getter=lambda: ReadConfig(), state=ReadState()) + + +@pytest.mark.asyncio +async def test_reads_entire_small_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "hello.txt").write_text("line one\nline two\n", encoding="utf-8") + tool = _make_read() + + result = await collect_result( + tool.run(ReadArgs(file_path=str(tmp_path / "hello.txt"))) + ) + + assert result.num_lines == 2 + assert result.total_lines == 2 + assert result.start_line == 1 + assert "line one" in result.content + assert "line two" in result.content + + +@pytest.mark.asyncio +async def test_reads_with_offset_and_limit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + content = "".join(f"line {i}\n" for i in range(1, 11)) + (tmp_path / "f.txt").write_text(content, encoding="utf-8") + tool = _make_read() + + result = await collect_result( + tool.run(ReadArgs(file_path=str(tmp_path / "f.txt"), offset=3, limit=2)) + ) + + assert result.num_lines == 2 + assert result.start_line == 3 + # Bounded read stops at the limit, so the true total is unknown. + assert result.total_lines is None + assert result.was_truncated is True + assert "line 3" in result.content + assert "line 4" in result.content + assert "line 2" not in result.content + assert "line 5" not in result.content + + +@pytest.mark.asyncio +async def test_empty_file_returns_warning( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "empty.txt").write_text("", encoding="utf-8") + tool = _make_read() + + result = await collect_result( + tool.run(ReadArgs(file_path=str(tmp_path / "empty.txt"))) + ) + + assert result.num_lines == 0 + assert VIBE_WARNING_TAG in result.content + assert "empty" in result.content + + +@pytest.mark.asyncio +async def test_offset_beyond_file_returns_warning( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "short.txt").write_text("one\ntwo\n", encoding="utf-8") + tool = _make_read() + + result = await collect_result( + tool.run(ReadArgs(file_path=str(tmp_path / "short.txt"), offset=100)) + ) + + assert result.num_lines == 0 + assert VIBE_WARNING_TAG in result.content + assert "shorter" in result.content + + +@pytest.mark.asyncio +async def test_exceeds_max_bytes_raises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + # Create file that generates output exceeding MAX_BYTES + big_line = "x" * 200 + "\n" + lines_needed = (MAX_BYTES // len(big_line)) + 100 + (tmp_path / "big.txt").write_text(big_line * lines_needed, encoding="utf-8") + tool = _make_read() + + with pytest.raises(ToolError, match="exceeds maximum allowed size"): + await collect_result(tool.run(ReadArgs(file_path=str(tmp_path / "big.txt")))) + + +@pytest.mark.asyncio +async def test_truncated_when_more_lines_than_limit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + content = "".join(f"line {i}\n" for i in range(1, 101)) + (tmp_path / "f.txt").write_text(content, encoding="utf-8") + tool = _make_read() + + result = await collect_result( + tool.run(ReadArgs(file_path=str(tmp_path / "f.txt"), limit=10)) + ) + + assert result.num_lines == 10 + assert result.was_truncated is True + assert result.total_lines is None + + +@pytest.mark.asyncio +async def test_single_oversized_line_raises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "wide.txt").write_text("x" * (MAX_BYTES + 10), encoding="utf-8") + tool = _make_read() + + with pytest.raises(ToolError, match="exceeds maximum allowed size"): + await collect_result(tool.run(ReadArgs(file_path=str(tmp_path / "wide.txt")))) + + +@pytest.mark.asyncio +async def test_file_not_found_raises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + tool = _make_read() + + with pytest.raises(ToolError, match="File not found"): + await collect_result(tool.run(ReadArgs(file_path=str(tmp_path / "nope.txt")))) + + +@pytest.mark.asyncio +async def test_empty_path_raises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + tool = _make_read() + + with pytest.raises(ToolError, match="file_path cannot be empty"): + await collect_result(tool.run(ReadArgs(file_path=""))) + + +@pytest.mark.asyncio +async def test_directory_raises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "adir").mkdir() + tool = _make_read() + + with pytest.raises(ToolError, match="directory"): + await collect_result(tool.run(ReadArgs(file_path=str(tmp_path / "adir")))) + + +@pytest.mark.asyncio +async def test_relative_path_resolved( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "f.txt").write_text("ok\n", encoding="utf-8") + tool = _make_read() + + result = await collect_result(tool.run(ReadArgs(file_path="sub/f.txt"))) + + assert result.num_lines == 1 + assert str(tmp_path / "sub" / "f.txt") == result.file_path + + +def test_line_number_format() -> None: + formatted = _add_line_numbers(["hello", "world"], start=1) + lines = formatted.split("\n") + assert lines[0] == " 1\u2192hello" + assert lines[1] == " 2\u2192world" + + +def test_default_limit_is_2000() -> None: + assert DEFAULT_LINE_LIMIT == 2000 + + +def test_format_call_display() -> None: + args = ReadArgs(file_path="/some/file.py") + display = Read.format_call_display(args) + + assert isinstance(display, ToolCallDisplay) + assert "file.py" in display.summary + + +def test_format_call_display_with_offset_limit() -> None: + args = ReadArgs(file_path="/some/file.py", offset=10, limit=50) + display = Read.format_call_display(args) + + assert "from line 10" in display.summary + assert "limit 50" in display.summary + + +def test_get_result_display() -> None: + result = ReadResult( + file_path="/path/to/foo.py", + content="...", + num_lines=10, + start_line=1, + total_lines=10, + ) + event = ToolResultEvent( + tool_call_id="test", tool_name="read", tool_class=None, result=result + ) + display = Read.get_result_display(event) + + assert isinstance(display, ToolResultDisplay) + assert display.success is True + assert "10 lines" in display.message + assert "foo.py" in display.message + + +def test_get_result_display_truncated() -> None: + result = ReadResult( + file_path="/path/to/foo.py", + content="...", + num_lines=10, + start_line=1, + total_lines=100, + ) + event = ToolResultEvent( + tool_call_id="test", tool_name="read", tool_class=None, result=result + ) + display = Read.get_result_display(event) + + assert "truncated" in display.message + + +def test_get_result_display_truncated_via_flag() -> None: + result = ReadResult( + file_path="/path/to/foo.py", + content="...", + num_lines=10, + start_line=1, + total_lines=None, + was_truncated=True, + ) + event = ToolResultEvent( + tool_call_id="test", tool_name="read", tool_class=None, result=result + ) + display = Read.get_result_display(event) + + assert "truncated" in display.message + + +@pytest.fixture() +def _setup_manager(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) + monkeypatch.setattr( + trusted_folders_manager, "find_trust_root", lambda _: tmp_path.resolve() + ) + reset_harness_files_manager() + init_harness_files_manager("user", "project") + yield + reset_harness_files_manager() + + +@pytest.mark.usefixtures("_setup_manager") +def test_agents_md_injection(tmp_path: Path) -> None: + sub = tmp_path / "sub" + sub.mkdir() + (sub / "AGENTS.md").write_text("# Sub instructions", encoding="utf-8") + target = sub / "file.py" + target.write_text("hello", encoding="utf-8") + + tool = _make_read() + result = ReadResult( + file_path=str(target), content="hello", num_lines=1, start_line=1, total_lines=1 + ) + annotation = tool.get_result_extra(result) + assert annotation is not None + assert VIBE_WARNING_TAG in annotation + assert "# Sub instructions" in annotation + + +@pytest.mark.usefixtures("_setup_manager") +def test_agents_md_deduplicates(tmp_path: Path) -> None: + sub = tmp_path / "sub" + sub.mkdir() + (sub / "AGENTS.md").write_text("# Sub", encoding="utf-8") + (sub / "a.py").write_text("a", encoding="utf-8") + (sub / "b.py").write_text("b", encoding="utf-8") + + tool = _make_read() + + r1 = ReadResult( + file_path=str(sub / "a.py"), + content="a", + num_lines=1, + start_line=1, + total_lines=1, + ) + assert tool.get_result_extra(r1) is not None + + r2 = ReadResult( + file_path=str(sub / "b.py"), + content="b", + num_lines=1, + start_line=1, + total_lines=1, + ) + assert tool.get_result_extra(r2) is None + + +def test_agents_md_returns_none_when_not_initialized(tmp_path: Path) -> None: + reset_harness_files_manager() + tool = _make_read() + result = ReadResult( + file_path=str(tmp_path / "file.py"), + content="", + num_lines=0, + start_line=1, + total_lines=0, + ) + assert tool.get_result_extra(result) is None + reset_harness_files_manager() diff --git a/tests/core/tools/builtins/test_read_file.py b/tests/core/tools/builtins/test_read_file.py deleted file mode 100644 index 1ab5964..0000000 --- a/tests/core/tools/builtins/test_read_file.py +++ /dev/null @@ -1,217 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator -from pathlib import Path - -import pytest - -from tests.mock.utils import collect_result -from vibe.core.config.harness_files import ( - init_harness_files_manager, - reset_harness_files_manager, -) -from vibe.core.tools.builtins.read_file import ( - ReadFile, - ReadFileArgs, - ReadFileResult, - ReadFileState, - ReadFileToolConfig, -) -from vibe.core.trusted_folders import trusted_folders_manager -from vibe.core.utils import VIBE_WARNING_TAG - - -@pytest.fixture() -def _setup_manager(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: - """Initialize harness files manager for tests, reset after.""" - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(trusted_folders_manager, "is_trusted", lambda _: True) - monkeypatch.setattr( - trusted_folders_manager, "find_trust_root", lambda _: tmp_path.resolve() - ) - reset_harness_files_manager() - init_harness_files_manager("user", "project") - yield - reset_harness_files_manager() - - -def _make_read_file() -> ReadFile: - return ReadFile(config_getter=lambda: ReadFileToolConfig(), state=ReadFileState()) - - -class TestReadFileExecution: - @pytest.mark.asyncio - async def test_run_with_large_offset_still_reads_lines( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.chdir(tmp_path) - test_file = tmp_path / "large_file.txt" - test_file.write_text( - "".join(f"line {i}\n" for i in range(200)), encoding="utf-8" - ) - tool = ReadFile( - config_getter=lambda: ReadFileToolConfig(max_read_bytes=64), - state=ReadFileState(), - ) - - result = await collect_result( - tool.run(ReadFileArgs(path=str(test_file), offset=50, limit=2)) - ) - - assert result.content == "line 50\nline 51\n" - assert result.lines_read == 2 - assert result.was_truncated - - @pytest.mark.asyncio - async def test_run_marks_truncated_when_max_read_bytes_exceeded( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.chdir(tmp_path) - test_file = tmp_path / "big.txt" - test_file.write_text( - "".join(f"line {i}\n" for i in range(100)), encoding="utf-8" - ) - tool = ReadFile( - config_getter=lambda: ReadFileToolConfig(max_read_bytes=20), - state=ReadFileState(), - ) - - result = await collect_result(tool.run(ReadFileArgs(path=str(test_file)))) - - assert result.was_truncated - assert result.lines_read > 0 - - @pytest.mark.asyncio - async def test_run_not_truncated_when_eof_reached( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.chdir(tmp_path) - test_file = tmp_path / "small.txt" - test_file.write_text("a\nb\nc\n", encoding="utf-8") - tool = ReadFile( - config_getter=lambda: ReadFileToolConfig(max_read_bytes=1024), - state=ReadFileState(), - ) - - result = await collect_result(tool.run(ReadFileArgs(path=str(test_file)))) - - assert not result.was_truncated - assert result.lines_read == 3 - - @pytest.mark.asyncio - async def test_run_not_truncated_when_limit_matches_remaining_lines( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.chdir(tmp_path) - test_file = tmp_path / "exact.txt" - test_file.write_text("a\nb\nc\n", encoding="utf-8") - tool = ReadFile( - config_getter=lambda: ReadFileToolConfig(max_read_bytes=1024), - state=ReadFileState(), - ) - - result = await collect_result( - tool.run(ReadFileArgs(path=str(test_file), limit=10)) - ) - - assert not result.was_truncated - assert result.lines_read == 3 - - -class TestGetResultExtra: - @pytest.mark.usefixtures("_setup_manager") - def test_returns_none_when_no_agents_md(self, tmp_path: Path) -> None: - sub = tmp_path / "sub" - sub.mkdir() - target = sub / "file.py" - target.write_text("hello", encoding="utf-8") - - tool = _make_read_file() - result = ReadFileResult( - path=str(target), content="hello", lines_read=1, was_truncated=False - ) - assert tool.get_result_extra(result) is None - - @pytest.mark.usefixtures("_setup_manager") - def test_returns_tagged_content_when_agents_md_found(self, tmp_path: Path) -> None: - sub = tmp_path / "sub" - sub.mkdir() - (sub / "AGENTS.md").write_text("# Sub instructions", encoding="utf-8") - target = sub / "file.py" - target.write_text("hello", encoding="utf-8") - - tool = _make_read_file() - result = ReadFileResult( - path=str(target), content="hello", lines_read=1, was_truncated=False - ) - annotation = tool.get_result_extra(result) - assert annotation is not None - assert f"<{VIBE_WARNING_TAG}>" in annotation - assert f"" in annotation - assert "# Sub instructions" in annotation - assert "project instructions for this directory" in annotation - - @pytest.mark.usefixtures("_setup_manager") - def test_deduplicates_across_calls(self, tmp_path: Path) -> None: - sub = tmp_path / "sub" - sub.mkdir() - (sub / "AGENTS.md").write_text("# Sub", encoding="utf-8") - file1 = sub / "a.py" - file2 = sub / "b.py" - file1.write_text("a", encoding="utf-8") - file2.write_text("b", encoding="utf-8") - - tool = _make_read_file() - - result1 = ReadFileResult( - path=str(file1), content="a", lines_read=1, was_truncated=False - ) - assert tool.get_result_extra(result1) is not None - - # Second call for a different file in the same dir → no duplicate - result2 = ReadFileResult( - path=str(file2), content="b", lines_read=1, was_truncated=False - ) - assert tool.get_result_extra(result2) is None - - @pytest.mark.usefixtures("_setup_manager") - def test_injects_new_dir_after_dedup(self, tmp_path: Path) -> None: - sub_a = tmp_path / "a" - sub_b = tmp_path / "b" - sub_a.mkdir() - sub_b.mkdir() - (sub_a / "AGENTS.md").write_text("# A", encoding="utf-8") - (sub_b / "AGENTS.md").write_text("# B", encoding="utf-8") - file_a = sub_a / "f.py" - file_b = sub_b / "f.py" - file_a.write_text("", encoding="utf-8") - file_b.write_text("", encoding="utf-8") - - tool = _make_read_file() - - r1 = ReadFileResult( - path=str(file_a), content="", lines_read=0, was_truncated=False - ) - ann1 = tool.get_result_extra(r1) - assert ann1 is not None - assert "# A" in ann1 - - # Different subdirectory → should inject its AGENTS.md - r2 = ReadFileResult( - path=str(file_b), content="", lines_read=0, was_truncated=False - ) - ann2 = tool.get_result_extra(r2) - assert ann2 is not None - assert "# B" in ann2 - - def test_returns_none_when_manager_not_initialized(self, tmp_path: Path) -> None: - reset_harness_files_manager() - tool = _make_read_file() - result = ReadFileResult( - path=str(tmp_path / "file.py"), - content="", - lines_read=0, - was_truncated=False, - ) - assert tool.get_result_extra(result) is None - reset_harness_files_manager() diff --git a/tests/onboarding/test_ui_onboarding.py b/tests/onboarding/test_ui_onboarding.py index c32f5ae..b49e7b9 100644 --- a/tests/onboarding/test_ui_onboarding.py +++ b/tests/onboarding/test_ui_onboarding.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable +from datetime import UTC, datetime from pathlib import Path import tomllib from typing import cast @@ -12,9 +13,12 @@ from textual.geometry import Size from textual.pilot import Pilot from textual.screen import Screen from textual.widget import Widget -from textual.widgets import Input, Link +from textual.widgets import Input, Link, Static -from tests.browser_sign_in.stubs import build_browser_sign_in_service_factory +from tests.browser_sign_in.stubs import ( + build_browser_sign_in_service_factory, + build_sign_in_process, +) from tests.conftest import build_test_vibe_config from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig @@ -33,8 +37,10 @@ from vibe.core.types import Backend from vibe.setup.auth import ( BrowserSignInError, BrowserSignInErrorCode, + BrowserSignInEvent, BrowserSignInService, BrowserSignInStatus, + BrowserSignInStatusChanged, ) from vibe.setup.auth.api_key_persistence import persist_api_key import vibe.setup.onboarding as onboarding_module @@ -42,11 +48,19 @@ from vibe.setup.onboarding import OnboardingApp from vibe.setup.onboarding.context import OnboardingContext from vibe.setup.onboarding.screens.api_key import ApiKeyScreen from vibe.setup.onboarding.screens.auth_method import AuthMethodScreen -from vibe.setup.onboarding.screens.browser_sign_in import BrowserSignInScreen +from vibe.setup.onboarding.screens.browser_sign_in import ( + SIGN_IN_URL_HELP_PREFIX, + BrowserSignInScreen, +) from vibe.setup.onboarding.screens.theme_selection import THEMES, ThemeSelectionScreen CONSOLE_URL = "https://console.mistral.ai" BROWSER_AUTH_API_URL = "https://console.mistral.ai/api" +TEST_NOW = datetime(2026, 3, 16, tzinfo=UTC) + + +def _expected_browser_sign_in_url(process_id: str = "process-1") -> str: + return build_sign_in_process(TEST_NOW, process_id=process_id).sign_in_url async def _wait_for( @@ -94,6 +108,8 @@ def _build_browser_onboarding_app( *, browser_sign_in_service_factory: Callable[[], BrowserSignInService] | None = None, browser_sign_in_success_delay: float = 0, + browser_sign_in_url_help_delay: float = 0, + copy_sign_in_url: Callable[[str], bool] | None = None, ) -> OnboardingApp: return OnboardingApp( config=_build_onboarding_config( @@ -102,6 +118,8 @@ def _build_browser_onboarding_app( ), browser_sign_in_service_factory=browser_sign_in_service_factory, browser_sign_in_success_delay=browser_sign_in_success_delay, + browser_sign_in_url_help_delay=browser_sign_in_url_help_delay, + copy_sign_in_url=copy_sign_in_url, ) @@ -157,6 +175,14 @@ def _browser_sign_in_step_text(card: Widget) -> str: return f"{title.render()}\n{detail.render()}" +def _browser_sign_in_hint(screen: Screen) -> str: + return str(screen.query_one("#browser-sign-in-hint", NoMarkupStatic).render()) + + +def _browser_sign_in_url_text(screen: Screen) -> str: + return str(screen.query_one("#browser-sign-in-url", Static).render()) + + def _build_unexpected_browser_sign_in_service_factory( outcomes: list[str], *, @@ -173,11 +199,13 @@ def _build_unexpected_browser_sign_in_service_factory( self._outcome = outcome async def authenticate( - self, status_callback: Callable[[BrowserSignInStatus], None] | None = None + self, event_callback: Callable[[BrowserSignInEvent], None] | None = None ) -> str: if self._outcome == "completed": - if status_callback is not None: - status_callback(BrowserSignInStatus.COMPLETED) + if event_callback is not None: + event_callback( + BrowserSignInStatusChanged(status=BrowserSignInStatus.COMPLETED) + ) return api_key if self._outcome == "runtime_error": raise RuntimeError("boom") @@ -328,15 +356,24 @@ async def test_ui_does_not_show_browser_opened_before_attempt_starts() -> None: authenticate_started = asyncio.Event() finish_authenticate = asyncio.Event() keep_authenticate_running = asyncio.Event() + copied_urls: list[str] = [] + + def copy_sign_in_url(url: str) -> bool: + copied_urls.append(url) + return True class DelayedBrowserSignInService: async def authenticate( - self, status_callback: Callable[[BrowserSignInStatus], None] | None = None + self, event_callback: Callable[[BrowserSignInEvent], None] | None = None ) -> str: authenticate_started.set() await finish_authenticate.wait() - if status_callback is not None: - status_callback(BrowserSignInStatus.OPENING_BROWSER) + if event_callback is not None: + event_callback( + BrowserSignInStatusChanged( + status=BrowserSignInStatus.OPENING_BROWSER + ) + ) await keep_authenticate_running.wait() return "sk-never-reached" @@ -346,7 +383,8 @@ async def test_ui_does_not_show_browser_opened_before_attempt_starts() -> None: app = _build_browser_onboarding_app( browser_sign_in_service_factory=lambda: cast( BrowserSignInService, DelayedBrowserSignInService() - ) + ), + copy_sign_in_url=copy_sign_in_url, ) async with app.run_test() as pilot: @@ -358,6 +396,9 @@ async def test_ui_does_not_show_browser_opened_before_attempt_starts() -> None: assert "Open browser" in active_step_text assert "Getting things ready..." in active_step_text assert "Browser opened" not in active_step_text + assert _browser_sign_in_url_text(app.screen) == "" + await pilot.press("c") + assert copied_urls == [] finish_authenticate.set() await _wait_for( @@ -371,6 +412,274 @@ async def test_ui_does_not_show_browser_opened_before_attempt_starts() -> None: ) +@pytest.mark.asyncio +async def test_ui_shows_browser_sign_in_url_copy_prompt_without_raw_url() -> None: + blocker = asyncio.Event() + + async def wait_forever(_: float) -> None: + await blocker.wait() + + _, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory( + outcomes=["completed"], sleep=wait_forever + ) + app = _build_browser_onboarding_app( + browser_sign_in_service_factory=browser_sign_in_service_factory + ) + + async with app.run_test() as pilot: + await _show_browser_sign_in(pilot) + await _wait_for( + lambda: "copy this URL" in _browser_sign_in_url_text(app.screen), pilot + ) + url_text = _browser_sign_in_url_text(app.screen) + assert "If your browser did not open, copy this URL (press C)" in url_text + assert "process-1" not in url_text + assert _browser_sign_in_hint(app.screen) == ( + "Press M to enter API key manually - Esc to cancel" + ) + + +@pytest.mark.asyncio +async def test_ui_delays_browser_sign_in_url_help() -> None: + blocker = asyncio.Event() + + async def wait_forever(_: float) -> None: + await blocker.wait() + + _, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory( + outcomes=["completed"], sleep=wait_forever + ) + app = _build_browser_onboarding_app( + browser_sign_in_service_factory=browser_sign_in_service_factory, + browser_sign_in_url_help_delay=0.3, + ) + + async with app.run_test() as pilot: + await _show_browser_sign_in(pilot) + await _wait_for( + lambda: ( + "Waiting for authentication..." + in _browser_sign_in_step_text( + _active_browser_sign_in_step_card(app.screen) + ) + ), + pilot, + ) + assert _browser_sign_in_url_text(app.screen) == "" + + await _wait_for( + lambda: "copy this URL" in _browser_sign_in_url_text(app.screen), pilot + ) + assert "process-1" not in _browser_sign_in_url_text(app.screen) + + +@pytest.mark.asyncio +async def test_ui_copies_browser_sign_in_url() -> None: + blocker = asyncio.Event() + copied_urls: list[str] = [] + + async def wait_forever(_: float) -> None: + await blocker.wait() + + def copy_sign_in_url(url: str) -> bool: + copied_urls.append(url) + return True + + _, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory( + outcomes=["completed"], sleep=wait_forever + ) + app = _build_browser_onboarding_app( + browser_sign_in_service_factory=browser_sign_in_service_factory, + copy_sign_in_url=copy_sign_in_url, + ) + + async with app.run_test() as pilot: + await _show_browser_sign_in(pilot) + await _wait_for( + lambda: "copy this URL" in _browser_sign_in_url_text(app.screen), pilot + ) + await pilot.press("c") + assert "process-1" not in _browser_sign_in_url_text(app.screen) + + assert copied_urls == [_expected_browser_sign_in_url()] + + +@pytest.mark.asyncio +async def test_ui_copies_browser_sign_in_url_when_help_text_is_clicked() -> None: + blocker = asyncio.Event() + copied_urls: list[str] = [] + + async def wait_forever(_: float) -> None: + await blocker.wait() + + def copy_sign_in_url(url: str) -> bool: + copied_urls.append(url) + return True + + _, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory( + outcomes=["completed"], sleep=wait_forever + ) + app = _build_browser_onboarding_app( + browser_sign_in_service_factory=browser_sign_in_service_factory, + copy_sign_in_url=copy_sign_in_url, + ) + + async with app.run_test() as pilot: + await _show_browser_sign_in(pilot) + await _wait_for( + lambda: "copy this URL" in _browser_sign_in_url_text(app.screen), pilot + ) + url_widget = app.screen.query_one("#browser-sign-in-url", Static) + link_x = url_widget.styles.padding.left + len(SIGN_IN_URL_HELP_PREFIX) + 1 + await pilot.click(url_widget, offset=(link_x, 0)) + + assert copied_urls == [_expected_browser_sign_in_url()] + + +@pytest.mark.asyncio +async def test_ui_reveals_browser_sign_in_url_when_copy_fails() -> None: + blocker = asyncio.Event() + + async def wait_forever(_: float) -> None: + await blocker.wait() + + _, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory( + outcomes=["completed"], sleep=wait_forever + ) + app = _build_browser_onboarding_app( + browser_sign_in_service_factory=browser_sign_in_service_factory, + copy_sign_in_url=lambda _: False, + ) + + async with app.run_test() as pilot: + await _show_browser_sign_in(pilot) + await _wait_for( + lambda: "copy this URL" in _browser_sign_in_url_text(app.screen), pilot + ) + assert "process-1" not in _browser_sign_in_url_text(app.screen) + + await pilot.press("c") + + await _wait_for( + lambda: "process-1" in _browser_sign_in_url_text(app.screen), pilot + ) + url_text = _browser_sign_in_url_text(app.screen) + assert "Copy failed. Open this URL manually:" in url_text + + +@pytest.mark.asyncio +async def test_ui_keeps_last_sign_in_url_copy_prompt_after_open_browser_failure() -> ( + None +): + _, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory( + outcomes=["completed"], open_browser=lambda _: False + ) + app = _build_browser_onboarding_app( + browser_sign_in_service_factory=browser_sign_in_service_factory + ) + + async with app.run_test() as pilot: + await _show_browser_sign_in(pilot) + await _wait_for( + lambda: ( + "Failed to open browser for sign-in." + in _browser_sign_in_step_text( + _active_browser_sign_in_step_card(app.screen) + ) + ), + pilot, + ) + assert _browser_sign_in_hint(app.screen) == ( + "Press R to retry - Press M to enter API key manually - Esc to cancel" + ) + url_text = _browser_sign_in_url_text(app.screen) + assert "If your browser did not open, copy this URL (press C)" in url_text + assert "process-1" not in url_text + + +@pytest.mark.asyncio +async def test_ui_copies_last_browser_sign_in_url_after_open_browser_failure() -> None: + copied_urls: list[str] = [] + + def copy_sign_in_url(url: str) -> bool: + copied_urls.append(url) + return True + + _, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory( + outcomes=["completed"], open_browser=lambda _: False + ) + app = _build_browser_onboarding_app( + browser_sign_in_service_factory=browser_sign_in_service_factory, + copy_sign_in_url=copy_sign_in_url, + ) + + async with app.run_test() as pilot: + await _show_browser_sign_in(pilot) + await _wait_for( + lambda: "copy this URL" in _browser_sign_in_url_text(app.screen), pilot + ) + + await pilot.press("c") + + assert copied_urls == [_expected_browser_sign_in_url()] + + +@pytest.mark.asyncio +async def test_ui_retry_hides_old_sign_in_url_and_uses_fresh_attempt_url() -> None: + blocker = asyncio.Event() + copied_urls: list[str] = [] + + async def wait_forever(_: float) -> None: + await blocker.wait() + + def copy_sign_in_url(url: str) -> bool: + copied_urls.append(url) + return True + + _, browser_sign_in_service_factory, _ = build_browser_sign_in_service_factory( + outcomes=["expired", "completed"], sleep=wait_forever + ) + app = _build_browser_onboarding_app( + browser_sign_in_service_factory=browser_sign_in_service_factory, + copy_sign_in_url=copy_sign_in_url, + ) + + async with app.run_test() as pilot: + await _show_browser_sign_in(pilot) + await _wait_for( + lambda: ( + "expired" + in _browser_sign_in_step_text( + _active_browser_sign_in_step_card(app.screen) + ) + ), + pilot, + ) + await _wait_for( + lambda: "copy this URL" in _browser_sign_in_url_text(app.screen), pilot + ) + + await pilot.press("r") + await _wait_for( + lambda: ( + "Waiting for authentication..." + in _browser_sign_in_step_text( + _active_browser_sign_in_step_card(app.screen) + ) + and "copy this URL" in _browser_sign_in_url_text(app.screen) + ), + pilot, + ) + assert "process-2" not in _browser_sign_in_url_text(app.screen) + assert "process-1" not in _browser_sign_in_url_text(app.screen) + assert _browser_sign_in_hint(app.screen) == ( + "Press M to enter API key manually - Esc to cancel" + ) + await pilot.press("c") + + assert copied_urls == [_expected_browser_sign_in_url(process_id="process-2")] + + @pytest.mark.asyncio async def test_ui_completes_browser_sign_in_and_retries_after_failure() -> None: gateway, browser_sign_in_service_factory, created_services = ( diff --git a/tests/session/test_saved_sessions.py b/tests/session/test_saved_sessions.py index e3b31d9..17c17da 100644 --- a/tests/session/test_saved_sessions.py +++ b/tests/session/test_saved_sessions.py @@ -6,7 +6,11 @@ from pathlib import Path import pytest from vibe.core.config import SessionLoggingConfig -from vibe.core.session.saved_sessions import update_saved_session_title +from vibe.core.session import last_session_pointer +from vibe.core.session.saved_sessions import ( + delete_saved_session, + update_saved_session_title, +) @pytest.fixture @@ -23,6 +27,21 @@ def session_config(temp_session_dir: Path) -> SessionLoggingConfig: ) +def write_saved_session( + session_config: SessionLoggingConfig, + timestamp: str, + short_id: str, + metadata: dict[str, object], +) -> Path: + saved_session_dir = Path(session_config.save_dir) / f"test_{timestamp}_{short_id}" + saved_session_dir.mkdir() + (saved_session_dir / "messages.jsonl").write_text( + '{"role": "user", "content": "Hello"}\n', encoding="utf-8" + ) + (saved_session_dir / "meta.json").write_text(json.dumps(metadata), encoding="utf-8") + return saved_session_dir + + class TestUpdateSavedSessionTitle: @pytest.mark.asyncio async def test_updates_saved_session_title_without_losing_existing_metadata( @@ -145,6 +164,22 @@ class TestUpdateSavedSessionTitle: "missing-session", "Renamed", session_config ) + @pytest.mark.asyncio + async def test_skips_non_object_candidate_metadata( + self, session_config: SessionLoggingConfig + ) -> None: + session_dir = Path(session_config.save_dir) + saved_session_dir = session_dir / "test_20240101_120000_eeeeeeee" + saved_session_dir.mkdir() + + (saved_session_dir / "messages.jsonl").write_text( + '{"role": "user", "content": "Hello"}\n', encoding="utf-8" + ) + (saved_session_dir / "meta.json").write_text("[]", encoding="utf-8") + + with pytest.raises(ValueError, match="Session not found: eeeeeeee-5555"): + await update_saved_session_title("eeeeeeee-5555", "Renamed", session_config) + @pytest.mark.asyncio async def test_requires_exact_saved_session_id( self, session_config: SessionLoggingConfig @@ -176,3 +211,120 @@ class TestUpdateSavedSessionTitle: assert ( json.loads(metadata_file.read_text(encoding="utf-8")) == original_metadata ) + + +class TestDeleteSavedSession: + @pytest.mark.asyncio + async def test_deletes_saved_session_directory( + self, session_config: SessionLoggingConfig + ) -> None: + target_dir = write_saved_session( + session_config, + "20240101_120000", + "ffffffff", + {"session_id": "ffffffff-6666", "title": "Target"}, + ) + other_dir = write_saved_session( + session_config, + "20240101_120000", + "11111111", + {"session_id": "11111111-7777", "title": "Other"}, + ) + + await delete_saved_session("ffffffff-6666", session_config) + + assert not target_dir.exists() + assert other_dir.exists() + + @pytest.mark.asyncio + async def test_clears_matching_last_session_pointers( + self, session_config: SessionLoggingConfig + ) -> None: + target_dir = write_saved_session( + session_config, + "20240101_120000", + "ffffffff", + {"session_id": "ffffffff-6666", "title": "Target"}, + ) + pointer_dir = ( + Path(session_config.save_dir) / last_session_pointer.POINTER_DIR_NAME + ) + pointer_dir.mkdir() + matching_pointer = pointer_dir / "ttys001" + other_pointer = pointer_dir / "ttys002" + matching_pointer.write_text("ffffffff-6666\n", encoding="utf-8") + other_pointer.write_text("other-session\n", encoding="utf-8") + + await delete_saved_session("ffffffff-6666", session_config) + + assert not target_dir.exists() + assert not matching_pointer.exists() + assert other_pointer.read_text(encoding="utf-8") == "other-session\n" + + @pytest.mark.asyncio + async def test_succeeds_for_missing_saved_session( + self, session_config: SessionLoggingConfig + ) -> None: + await delete_saved_session("missing-session", session_config) + + @pytest.mark.asyncio + async def test_clears_stale_last_session_pointer_for_missing_saved_session( + self, session_config: SessionLoggingConfig + ) -> None: + pointer_dir = ( + Path(session_config.save_dir) / last_session_pointer.POINTER_DIR_NAME + ) + pointer_dir.mkdir() + matching_pointer = pointer_dir / "ttys001" + other_pointer = pointer_dir / "ttys002" + matching_pointer.write_text("missing-session\n", encoding="utf-8") + other_pointer.write_text("other-session\n", encoding="utf-8") + + await delete_saved_session("missing-session", session_config) + + assert not matching_pointer.exists() + assert other_pointer.read_text(encoding="utf-8") == "other-session\n" + + @pytest.mark.asyncio + async def test_requires_exact_saved_session_id_before_deleting( + self, session_config: SessionLoggingConfig + ) -> None: + collision_dir = write_saved_session( + session_config, + "20240101_120000", + "aaaaaaaa", + {"session_id": "aaaaaaaa-1111", "title": "Collision"}, + ) + target_dir = write_saved_session( + session_config, + "20240101_120500", + "aaaaaaaa", + {"session_id": "aaaaaaaa-2222", "title": "Target"}, + ) + + await delete_saved_session("aaaaaaaa-2222", session_config) + + assert collision_dir.exists() + assert not target_dir.exists() + + @pytest.mark.asyncio + async def test_skips_invalid_candidate_metadata_before_deleting( + self, session_config: SessionLoggingConfig + ) -> None: + invalid_dir = Path(session_config.save_dir) / "test_20240101_120000_bbbbbbbb" + invalid_dir.mkdir() + (invalid_dir / "messages.jsonl").write_text( + '{"role": "user", "content": "Hello"}\n', encoding="utf-8" + ) + (invalid_dir / "meta.json").write_text("[]", encoding="utf-8") + target_dir = write_saved_session( + session_config, + "20240101_120500", + "bbbbbbbb", + {"session_id": "bbbbbbbb-3333", "title": "Target"}, + ) + + await delete_saved_session("bbbbbbbb-3333", session_config) + + assert invalid_dir.exists() + assert not target_dir.exists() diff --git a/tests/skills/test_manager.py b/tests/skills/test_manager.py index bafd56f..3643b28 100644 --- a/tests/skills/test_manager.py +++ b/tests/skills/test_manager.py @@ -115,7 +115,7 @@ class TestSkillManagerParsing: license="MIT", compatibility="Requires git", metadata={"author": "Test Author", "version": "1.0"}, - allowed_tools="bash read_file", + allowed_tools="bash read", ) config = build_test_vibe_config( @@ -132,7 +132,7 @@ class TestSkillManagerParsing: assert skill.license == "MIT" assert skill.compatibility == "Requires git" assert skill.metadata == {"author": "Test Author", "version": "1.0"} - assert skill.allowed_tools == ["bash", "read_file"] + assert skill.allowed_tools == ["bash", "read"] def test_sets_correct_skill_path(self, skills_dir: Path) -> None: create_skill(skills_dir, "test-skill", "A test skill") @@ -570,6 +570,47 @@ class TestParseSkillCommand: assert parsed.name == "my-skill" +class TestSkillManagerConfigIssues: + def test_invalid_frontmatter_records_config_issue(self, skills_dir: Path) -> None: + broken_dir = skills_dir / "broken-skill" + broken_dir.mkdir() + (broken_dir / "SKILL.md").write_text("No frontmatter here") + + config = build_test_vibe_config( + system_prompt_id="tests", + include_project_context=False, + skill_paths=[skills_dir], + ) + manager = SkillManager(lambda: config) + + assert len(manager.config_issues) == 1 + issue = manager.config_issues[0] + assert issue.file == broken_dir / "SKILL.md" + assert "Failed to load" in issue.message + + def test_multiple_invalid_skills_accumulate_issues(self, skills_dir: Path) -> None: + broken_dir_a = skills_dir / "broken-skill-a" + broken_dir_a.mkdir() + (broken_dir_a / "SKILL.md").write_text("not valid") + broken_dir = skills_dir / "broken-skill" + broken_dir.mkdir() + (broken_dir / "SKILL.md").write_text("not valid") + create_skill(skills_dir, "good-skill", "Works fine") + + config = build_test_vibe_config( + system_prompt_id="tests", + include_project_context=False, + skill_paths=[skills_dir], + ) + manager = SkillManager(lambda: config) + + assert { + (i.file, i.message.startswith("Failed to load")) + for i in manager.config_issues + } == {(broken_dir_a / "SKILL.md", True), (broken_dir / "SKILL.md", True)} + assert "good-skill" in manager.available_skills + + class TestBuildSkillPrompt: def test_without_args(self, skills_dir: Path, skill_config: VibeConfig) -> None: create_skill(skills_dir, "my-skill", body="Do the thing.") diff --git a/tests/skills/test_models.py b/tests/skills/test_models.py index e8731c2..eccdcd7 100644 --- a/tests/skills/test_models.py +++ b/tests/skills/test_models.py @@ -27,7 +27,7 @@ class TestSkillMetadata: license="MIT", compatibility="Requires git", metadata={"author": "Test Author", "version": "1.0"}, - allowed_tools=["bash", "read_file"], + allowed_tools=["bash", "read"], user_invocable=False, ) @@ -36,7 +36,7 @@ class TestSkillMetadata: assert meta.license == "MIT" assert meta.compatibility == "Requires git" assert meta.metadata == {"author": "Test Author", "version": "1.0"} - assert meta.allowed_tools == ["bash", "read_file"] + assert meta.allowed_tools == ["bash", "read"] assert meta.user_invocable is False def test_raises_error_for_uppercase_name(self) -> None: @@ -63,17 +63,17 @@ class TestSkillMetadata: meta = SkillMetadata( name="test", description="A test skill", - allowed_tools="bash read_file grep", # type: ignore[arg-type] + allowed_tools="bash read grep", # type: ignore[arg-type] ) - assert meta.allowed_tools == ["bash", "read_file", "grep"] + assert meta.allowed_tools == ["bash", "read", "grep"] def test_parses_allowed_tools_from_list(self) -> None: meta = SkillMetadata( - name="test", description="A test skill", allowed_tools=["bash", "read_file"] + name="test", description="A test skill", allowed_tools=["bash", "read"] ) - assert meta.allowed_tools == ["bash", "read_file"] + assert meta.allowed_tools == ["bash", "read"] def test_parses_allowed_tools_handles_none(self) -> None: meta = SkillMetadata( diff --git a/tests/skills/test_parser.py b/tests/skills/test_parser.py index 69508a3..af0718a 100644 --- a/tests/skills/test_parser.py +++ b/tests/skills/test_parser.py @@ -29,7 +29,7 @@ compatibility: Requires git metadata: author: Test Author version: "1.0" -allowed-tools: bash read_file +allowed-tools: bash read --- Instructions here. @@ -42,7 +42,7 @@ Instructions here. assert frontmatter["compatibility"] == "Requires git" assert frontmatter["metadata"]["author"] == "Test Author" assert frontmatter["metadata"]["version"] == "1.0" - assert frontmatter["allowed-tools"] == "bash read_file" + assert frontmatter["allowed-tools"] == "bash read" assert "Instructions here." in body def test_raises_error_for_missing_frontmatter(self) -> None: diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_issues/test_snapshot_shows_config_issue_notification.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_issues/test_snapshot_shows_config_issue_notification.svg new file mode 100644 index 0000000..b3d8066 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_issues/test_snapshot_shows_config_issue_notification.svg @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppWithConfigIssues + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information +/test/skills/broken-skill/SKILL.md +Failed to load: missing required field 'description' + +────────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─ +> + + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_issues/test_snapshot_shows_hook_config_issue_notification.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_issues/test_snapshot_shows_hook_config_issue_notification.svg new file mode 100644 index 0000000..18c06bb --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_issues/test_snapshot_shows_hook_config_issue_notification.svg @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppWithHookConfigIssue + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information +/test/hooks/broken-hook.toml +Failed to parse: invalid TOML syntax + +────────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─ +> + + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_after_resize.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_after_resize.svg new file mode 100644 index 0000000..eb8ec75 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_after_resize.svg @@ -0,0 +1,222 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WriteApprovalLongContentApp + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +Permission for the write_file tool +line_086=602 +line_087=609 +line_088=616 +line_089=623 +line_090=630 +line_091=637 +line_092=644 +line_093=651 +line_094=658 +line_095=665 +line_096=672 +line_097=679 +line_098=686 +line_099=693 +line_100=700 + +› 1. Allow once + +  2. Allow for remainder of this session + +  3. Always allow + +  4. Deny + +↑↓ navigate  Enter select  ESC reject +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_bottom_lines_hidden.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_bottom_lines_hidden.svg new file mode 100644 index 0000000..640ec4a --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_long_content_bottom_lines_hidden.svg @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WriteApprovalLongContentApp + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +Permission for the write_file tool +line_093=651 +line_094=658 +line_095=665 +line_096=672 +line_097=679 +line_098=686 +line_099=693 +line_100=700 + +› 1. Allow once + +  2. Allow for remainder of this session + +  3. Always allow + +  4. Deny + +↑↓ navigate  Enter select  ESC reject +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_short_content.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_short_content.svg new file mode 100644 index 0000000..4dfbc50 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_diff_view_truncation/test_snapshot_write_approval_short_content.svg @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WriteApprovalShortContentApp + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +Permission for the write_file tool +File: src/example.py + +line_001=7 + +› 1. Allow once + +  2. Allow for remainder of this session + +  3. Always allow + +  4. Deny + +↑↓ navigate  Enter select  ESC reject +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_textbox_rewrites_typed_image_path.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_textbox_rewrites_typed_image_path.svg new file mode 100644 index 0000000..106863e --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_textbox_rewrites_typed_image_path.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + _ImageAttachmentApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + +────────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─ +>look @/snap/shot.png                                                                                                   + + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_user_message_with_image_footer_plural.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_user_message_with_image_footer_plural.svg new file mode 100644 index 0000000..a7c75ca --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_user_message_with_image_footer_plural.svg @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + _ImageAttachmentApp + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + +compare @a.png @b.png +└ attached images: a.pngb.png +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +Got it. + + +────────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─ +> + + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_user_message_with_image_footer_singular.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_user_message_with_image_footer_singular.svg new file mode 100644 index 0000000..e182bdd --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_image_attachments/test_snapshot_user_message_with_image_footer_singular.svg @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + _ImageAttachmentApp + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + +look @shot.png +└ attached image: shot.png +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +Got it. + + +────────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─ +> + + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_back_to_mcp.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_back_to_mcp.svg index 832cb25..8f46bfd 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_back_to_mcp.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_back_to_mcp.svg @@ -180,7 +180,7 @@   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 3 connectors · 0 MCP servers · 0 skills + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 1/3 connectors · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_opens_on_disconnected.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_opens_on_disconnected.svg index 13ea4dc..63188e7 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_opens_on_disconnected.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_opens_on_disconnected.svg @@ -174,7 +174,7 @@   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 3 connectors · 0 MCP servers · 0 skills + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 1/3 connectors · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_show_url.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_show_url.svg index a986457..26487e9 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_show_url.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_show_url.svg @@ -172,7 +172,7 @@   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 3 connectors · 0 MCP servers · 0 skills + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 1/3 connectors · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg index e6f2a0c..220e4f1 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg @@ -181,7 +181,7 @@   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 3 connectors · 0 MCP servers · 0 skills + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 1/3 connectors · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_connect.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_connect.svg index 034060c..8bc5bb5 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_connect.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_connect.svg @@ -181,7 +181,7 @@   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 3 connectors · 0 MCP servers · 0 skills + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 1/3 connectors · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg index c81c9b2..3dbc8f1 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg @@ -188,7 +188,7 @@ I'm doing well, thank you! Let me read that file for you. -read_file +read ────────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_tool_call/test_snapshot_tool_call_partial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_tool_call/test_snapshot_tool_call_partial.svg index fe4fd95..6262c25 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_tool_call/test_snapshot_tool_call_partial.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_tool_call/test_snapshot_tool_call_partial.svg @@ -77,10 +77,10 @@ - + -read_file +read diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_trust_folder_dialog/test_snapshot_trust_folder_dialog.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_trust_folder_dialog/test_snapshot_trust_folder_dialog.svg new file mode 100644 index 0000000..0519c37 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_trust_folder_dialog/test_snapshot_trust_folder_dialog.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TrustFolderDialogSnapshotApp + + + + + + + + + + + + + + + + +╭────────────────────────────────────────────────────────────────────╮ + +Trust this folder? + +/home/user/projects/my-project + +Files here can modify AI behavior. Malicious configs may +exfiltrate data, run destructive commands, or silently +alter your code. + +Detected in current folder: +• AGENTS.md +• .vibe/ + +────────────────────────────────────────────────────────── +Only trust folders you fully control +────────────────────────────────────────────────────────── + +  Trust folder› Don't trust + +← → navigate  Enter select + +Setting will be saved in: +/home/user/.vibe/trusted_folders.toml + +╰────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_trust_folder_dialog/test_snapshot_trust_folder_dialog_many_files.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_trust_folder_dialog/test_snapshot_trust_folder_dialog_many_files.svg new file mode 100644 index 0000000..6347d37 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_trust_folder_dialog/test_snapshot_trust_folder_dialog_many_files.svg @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TrustFolderDialogManyFilesSnapshotApp + + + + + + + + + + + + + + + +╭────────────────────────────────────────────────────────────────────╮ + +Trust this folder? + +/home/user/projects/my-project + +Files here can modify AI behavior. Malicious configs may +exfiltrate data, run destructive commands, or silently +alter your code. + +Detected in current folder: +• sub0/AGENTS.md +• sub1/AGENTS.md +• sub2/AGENTS.md +• sub3/AGENTS.md +• sub4/AGENTS.md +────────────────────────────────────────────────────────── +Only trust folders you fully control +────────────────────────────────────────────────────────── + +  Trust folder› Don't trust + +← → navigate  Enter select + +Setting will be saved in: +/home/user/.vibe/trusted_folders.toml + +╰────────────────────────────────────────────────────────────────────╯ + + + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_trust_folder_dialog/test_snapshot_trust_folder_dialog_small_terminal.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_trust_folder_dialog/test_snapshot_trust_folder_dialog_small_terminal.svg new file mode 100644 index 0000000..8f040bf --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_trust_folder_dialog/test_snapshot_trust_folder_dialog_small_terminal.svg @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TrustFolderDialogSnapshotApp + + + + + + + + + + ╭────────────────────────────────────────────────────────────────────╮ + +Trust this folder? + +/home/user/projects/my-project + +Files here can modify AI behavior. Malicious configs may +exfiltrate data, run destructive commands, or silently +alter your code. + +Detected in current folder: +• AGENTS.md +• .vibe/ + +───────────────────────────────────────────────────────── +Only trust folders you fully control +───────────────────────────────────────────────────────── + +  Trust folder› Don't trust + +← → navigate  Enter select + + +╰────────────────────────────────────────────────────────────────────╯ + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_trust_folder_dialog/test_snapshot_trust_folder_dialog_untrusted_repo.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_trust_folder_dialog/test_snapshot_trust_folder_dialog_untrusted_repo.svg new file mode 100644 index 0000000..6ee4197 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_trust_folder_dialog/test_snapshot_trust_folder_dialog_untrusted_repo.svg @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TrustFolderDialogUntrustedRepoSnapshotApp + + + + + + + + + + + + + + + +╭────────────────────────────────────────────────────────────────────╮ + +Trust this folder? + +/home/user/projects/my-project/src/pkg +⚠ git repository /home/user/projects/my-project is marked +untrusted + +Files here can modify AI behavior. Malicious configs may +exfiltrate data, run destructive commands, or silently +alter your code. + +Detected in current folder: +• AGENTS.md + +────────────────────────────────────────────────────────── +Only trust folders you fully control +────────────────────────────────────────────────────────── + +  Trust folder› Don't trust + +← → navigate  Enter select + +Setting will be saved in: +/home/user/.vibe/trusted_folders.toml + +╰────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_trust_folder_dialog/test_snapshot_trust_folder_dialog_with_repo.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_trust_folder_dialog/test_snapshot_trust_folder_dialog_with_repo.svg new file mode 100644 index 0000000..5e65cd7 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_trust_folder_dialog/test_snapshot_trust_folder_dialog_with_repo.svg @@ -0,0 +1,221 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TrustFolderDialogWithRepoSnapshotApp + + + + + + + + + + + + + + +╭────────────────────────────────────────────────────────────────────╮ + +Trust folder or repository? + +/home/user/projects/my-project/src/pkg +↳ git repository: /home/user/projects/my-project + +Files here can modify AI behavior. Malicious configs may +exfiltrate data, run destructive commands, or silently +alter your code. + +Detected in current folder: +• AGENTS.md + +Detected in repository context: +• .vibe/ +• src/AGENTS.md +────────────────────────────────────────────────────────── +Only trust folders you fully control +────────────────────────────────────────────────────────── + +  Trust full repo  Trust folder› Don't trust + +← → navigate  Enter select + +Setting will be saved in: +/home/user/.vibe/trusted_folders.toml + +╰────────────────────────────────────────────────────────────────────╯ + + + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg index 4a6b70c..e9f2440 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg @@ -179,7 +179,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [API] Experiment plan +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · Free  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg index 73490ac..60c4dff 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg @@ -179,7 +179,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [API] Experiment plan +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · Free  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/base_snapshot_test_app.py b/tests/snapshots/base_snapshot_test_app.py index 39362ca..7623ca6 100644 --- a/tests/snapshots/base_snapshot_test_app.py +++ b/tests/snapshots/base_snapshot_test_app.py @@ -63,8 +63,9 @@ class BaseSnapshotTestApp(VibeApp): agent_loop=agent_loop, plan_offer_gateway=plan_offer_gateway, **kwargs ) - async def on_mount(self) -> None: - await super().on_mount() + async def on_ready(self): + # on_ready is called once all the on_mount in the MRO chain have been called + # https://textual.textualize.io/api/events/#textual.events.Ready self._hide_chat_input_cursor() def _hide_chat_input_cursor(self) -> None: diff --git a/tests/snapshots/test_ui_snapshot_config_issues.py b/tests/snapshots/test_ui_snapshot_config_issues.py new file mode 100644 index 0000000..9d9d070 --- /dev/null +++ b/tests/snapshots/test_ui_snapshot_config_issues.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pathlib import Path + +from textual.pilot import Pilot + +from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp +from tests.snapshots.snap_compare import SnapCompare +from vibe.core.hooks.models import HookConfigIssue +from vibe.core.skills.models import SkillConfigIssue + + +class SnapshotTestAppWithConfigIssues(BaseSnapshotTestApp): + def __init__(self) -> None: + super().__init__() + self.agent_loop.skill_manager._config_issues = [ + SkillConfigIssue( + file=Path("/test/skills/broken-skill/SKILL.md"), + message="Failed to load: missing required field 'description'", + ) + ] + + +class SnapshotTestAppWithHookConfigIssue(BaseSnapshotTestApp): + def __init__(self) -> None: + super().__init__() + self.agent_loop.hook_config_issues = [ + HookConfigIssue( + file=Path("/test/hooks/broken-hook.toml"), + message="Failed to parse: invalid TOML syntax", + ) + ] + + +def test_snapshot_shows_config_issue_notification(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.3) + + assert snap_compare( + "test_ui_snapshot_config_issues.py:SnapshotTestAppWithConfigIssues", + terminal_size=(120, 36), + run_before=run_before, + ) + + +def test_snapshot_shows_hook_config_issue_notification( + snap_compare: SnapCompare, +) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.3) + + assert snap_compare( + "test_ui_snapshot_config_issues.py:SnapshotTestAppWithHookConfigIssue", + terminal_size=(120, 36), + run_before=run_before, + ) diff --git a/tests/snapshots/test_ui_snapshot_diff_view_truncation.py b/tests/snapshots/test_ui_snapshot_diff_view_truncation.py new file mode 100644 index 0000000..8522b8a --- /dev/null +++ b/tests/snapshots/test_ui_snapshot_diff_view_truncation.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from textual.containers import VerticalScroll +from textual.pilot import Pilot + +from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp +from tests.snapshots.snap_compare import SnapCompare +from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp +from vibe.core.tools.builtins.write_file import WriteFileArgs + +WF_CONTENT_LONG = "\n".join(f"line_{i:03d} = {i * 7}" for i in range(1, 101)) +WF_CONTENT_SHORT = "line_001 = 7" + + +class WriteApprovalLongContentApp(BaseSnapshotTestApp): + async def on_ready(self) -> None: + args = WriteFileArgs(path="src/example.py", content=WF_CONTENT_LONG) + await self._switch_to_approval_app("write_file", args) + + +class WriteApprovalShortContentApp(BaseSnapshotTestApp): + async def on_ready(self) -> None: + args = WriteFileArgs(path="src/example.py", content=WF_CONTENT_SHORT) + await self._switch_to_approval_app("write_file", args) + + +def test_snapshot_write_approval_long_content_bottom_lines_hidden( + snap_compare: SnapCompare, +) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.3) + approval = pilot.app.query_one(ApprovalApp) + scroll = approval.query_one(".approval-tool-info-scroll", VerticalScroll) + scroll.scroll_end(animate=False, immediate=True) + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_diff_view_truncation.py:WriteApprovalLongContentApp", + terminal_size=(100, 30), + run_before=run_before, + ) + + +def test_snapshot_write_approval_short_content(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.3) + + assert snap_compare( + "test_ui_snapshot_diff_view_truncation.py:WriteApprovalShortContentApp", + terminal_size=(100, 30), + run_before=run_before, + ) + + +def test_snapshot_write_approval_long_content_after_resize( + snap_compare: SnapCompare, +) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.3) + await pilot.resize_terminal(120, 40) + await pilot.pause(0.2) + approval = pilot.app.query_one(ApprovalApp) + scroll = approval.query_one(".approval-tool-info-scroll", VerticalScroll) + scroll.scroll_end(animate=False, immediate=True) + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_diff_view_truncation.py:WriteApprovalLongContentApp", + terminal_size=(100, 30), + run_before=run_before, + ) diff --git a/tests/snapshots/test_ui_snapshot_image_attachments.py b/tests/snapshots/test_ui_snapshot_image_attachments.py new file mode 100644 index 0000000..66fd6ec --- /dev/null +++ b/tests/snapshots/test_ui_snapshot_image_attachments.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from textual.pilot import Pilot + +from tests.conftest import build_test_vibe_config +from tests.mock.utils import mock_llm_chunk +from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config +from tests.snapshots.snap_compare import SnapCompare +from tests.stubs.fake_backend import FakeBackend +from vibe.cli.textual_ui.widgets.chat_input import paste_path +from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig +from vibe.core.types import Backend + +PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 +SNAP_ROOT = "/snap" + + +def _vision_config() -> VibeConfig: + base = default_config() + models = [ + ModelConfig( + name="mistral-vibe-cli-latest", + provider="mistral", + alias="devstral-latest", + supports_images=True, + ) + ] + providers = [ + ProviderConfig( + name="mistral", + api_base="https://api.mistral.ai/v1", + api_key_env_var="MISTRAL_API_KEY", + backend=Backend.MISTRAL, + ) + ] + return build_test_vibe_config( + disable_welcome_banner_animation=True, + displayed_workdir=base.displayed_workdir, + active_model="devstral-latest", + models=models, + providers=providers, + ) + + +@pytest.fixture(autouse=True) +def _accept_snap_paths_as_images(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_is_image_file(candidate: str) -> bool: + return candidate.startswith(f"{SNAP_ROOT}/") and candidate.endswith(( + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + )) + + monkeypatch.setattr(paste_path, "_is_image_file", fake_is_image_file) + + +class _ImageAttachmentApp(BaseSnapshotTestApp): + def __init__(self) -> None: + super().__init__( + config=_vision_config(), + backend=FakeBackend(mock_llm_chunk(content="Got it.")), + ) + + +def test_snapshot_textbox_rewrites_typed_image_path(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.press(*f"look {SNAP_ROOT}/shot.png") + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_image_attachments.py:_ImageAttachmentApp", + terminal_size=(120, 36), + run_before=run_before, + ) + + +def test_snapshot_user_message_with_image_footer_singular( + snap_compare: SnapCompare, tmp_working_directory: Path +) -> None: + (tmp_working_directory / "shot.png").write_bytes(PNG_BYTES) + + async def run_before(pilot: Pilot) -> None: + # Trailing space dismisses the @-mention autocomplete popup so that + # `enter` submits the message instead of accepting a suggestion. + await pilot.press(*"look @shot.png ") + await pilot.press("enter") + await pilot.pause(0.4) + + assert snap_compare( + "test_ui_snapshot_image_attachments.py:_ImageAttachmentApp", + terminal_size=(120, 36), + run_before=run_before, + ) + + +def test_snapshot_user_message_with_image_footer_plural( + snap_compare: SnapCompare, tmp_working_directory: Path +) -> None: + (tmp_working_directory / "a.png").write_bytes(PNG_BYTES) + (tmp_working_directory / "b.png").write_bytes(PNG_BYTES) + + async def run_before(pilot: Pilot) -> None: + await pilot.press(*"compare @a.png @b.png ") + await pilot.press("enter") + await pilot.pause(0.4) + + assert snap_compare( + "test_ui_snapshot_image_attachments.py:_ImageAttachmentApp", + terminal_size=(120, 36), + run_before=run_before, + ) diff --git a/tests/snapshots/test_ui_snapshot_mcp_command.py b/tests/snapshots/test_ui_snapshot_mcp_command.py index c57e2db..ee9c336 100644 --- a/tests/snapshots/test_ui_snapshot_mcp_command.py +++ b/tests/snapshots/test_ui_snapshot_mcp_command.py @@ -174,7 +174,8 @@ def test_snapshot_mcp_refresh_shortcut(snap_compare: SnapCompare) -> None: async def run_before(pilot: Pilot) -> None: await _run_mcp_command(pilot, "/mcp") await pilot.press("r") - await pilot.pause(0.2) + await pilot.app.workers.wait_for_complete() + await pilot.pause(0.1) assert snap_compare( "test_ui_snapshot_mcp_command.py:SnapshotTestAppWithMcpServers", diff --git a/tests/snapshots/test_ui_snapshot_parallel_tool_calls.py b/tests/snapshots/test_ui_snapshot_parallel_tool_calls.py index 1244cb5..fa58d34 100644 --- a/tests/snapshots/test_ui_snapshot_parallel_tool_calls.py +++ b/tests/snapshots/test_ui_snapshot_parallel_tool_calls.py @@ -10,7 +10,7 @@ from textual.widget import Widget from tests.snapshots.snap_compare import SnapCompare from vibe.cli.textual_ui.handlers.event_handler import EventHandler from vibe.cli.textual_ui.widgets.tools import ToolCallMessage -from vibe.core.tools.builtins.read_file import ReadFile, ReadFileArgs, ReadFileResult +from vibe.core.tools.builtins.read import Read, ReadArgs, ReadResult from vibe.core.types import ToolCallEvent, ToolResultEvent @@ -49,9 +49,9 @@ class ParallelToolCallsApp(App): ToolCallEvent( tool_call_id=f"tc_{i}", tool_call_index=i, - tool_name="read_file", - tool_class=ReadFile, - args=ReadFileArgs(path=f"/src/file_{i}.py"), + tool_name="read", + tool_class=Read, + args=ReadArgs(file_path=f"/src/file_{i}.py"), ) ) @@ -71,13 +71,14 @@ class ParallelToolCallsApp(App): for i in range(3): await self._handler.handle_event( ToolResultEvent( - tool_name="read_file", - tool_class=ReadFile, - result=ReadFileResult( - path=f"/src/file_{i}.py", - content=f"# content of file_{i}.py", - lines_read=1, - was_truncated=False, + tool_name="read", + tool_class=Read, + result=ReadResult( + file_path=f"/src/file_{i}.py", + content=f" 1→# content of file_{i}.py", + num_lines=1, + start_line=1, + total_lines=1, ), tool_call_id=f"tc_{i}", ) diff --git a/tests/snapshots/test_ui_snapshot_release_update_notification.py b/tests/snapshots/test_ui_snapshot_release_update_notification.py index cf105f5..cbec0e4 100644 --- a/tests/snapshots/test_ui_snapshot_release_update_notification.py +++ b/tests/snapshots/test_ui_snapshot_release_update_notification.py @@ -11,7 +11,7 @@ from tests.update_notifier.adapters.fake_update_cache_repository import ( FakeUpdateCacheRepository, ) from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway -from vibe.cli.update_notifier import Update +from vibe.cli.update_notifier import Update, UpdateCache class SnapshotTestAppWithUpdate(BaseSnapshotTestApp): @@ -19,7 +19,13 @@ class SnapshotTestAppWithUpdate(BaseSnapshotTestApp): config = default_config() config.enable_update_checks = True update_notifier = FakeUpdateGateway(update=Update(latest_version="1000.2.0")) - update_cache_repository = FakeUpdateCacheRepository() + update_cache_repository = FakeUpdateCacheRepository( + UpdateCache( + latest_version="1.0.4", + stored_at_timestamp=0, + seen_whats_new_version=None, + ) + ) super().__init__( config=config, update_notifier=update_notifier, diff --git a/tests/snapshots/test_ui_snapshot_session_resume.py b/tests/snapshots/test_ui_snapshot_session_resume.py index 6e25022..35d8778 100644 --- a/tests/snapshots/test_ui_snapshot_session_resume.py +++ b/tests/snapshots/test_ui_snapshot_session_resume.py @@ -20,7 +20,7 @@ class SnapshotTestAppWithResumedSession(BaseSnapshotTestApp): id="tool_call_1", index=0, function=FunctionCall( - name="read_file", arguments='{"path": "test.txt"}' + name="read", arguments='{"file_path": "test.txt"}' ), ) ], @@ -28,7 +28,7 @@ class SnapshotTestAppWithResumedSession(BaseSnapshotTestApp): tool_result_msg = LLMMessage( role=Role.tool, content="File content: This is a test file with some content.", - name="read_file", + name="read", tool_call_id="tool_call_1", ) diff --git a/tests/snapshots/test_ui_snapshot_streaming_tool_call.py b/tests/snapshots/test_ui_snapshot_streaming_tool_call.py index 9d94c22..2cf6199 100644 --- a/tests/snapshots/test_ui_snapshot_streaming_tool_call.py +++ b/tests/snapshots/test_ui_snapshot_streaming_tool_call.py @@ -10,7 +10,7 @@ from textual.pilot import Pilot from tests.snapshots.snap_compare import SnapCompare from vibe.cli.textual_ui.widgets.status_message import StatusMessage from vibe.cli.textual_ui.widgets.tools import ToolCallMessage -from vibe.core.tools.builtins.read_file import ReadFile, ReadFileArgs +from vibe.core.tools.builtins.read import Read, ReadArgs from vibe.core.types import ToolCallEvent @@ -25,8 +25,8 @@ class ToolCallStreamingUpdateTest(App): partial_event = ToolCallEvent( tool_call_id="tc_streaming", tool_call_index=0, - tool_name="read_file", - tool_class=ReadFile, + tool_name="read", + tool_class=Read, args=None, ) self._widget = ToolCallMessage(partial_event) @@ -41,9 +41,9 @@ class ToolCallStreamingUpdateTest(App): full_event = ToolCallEvent( tool_call_id="tc_streaming", tool_call_index=0, - tool_name="read_file", - tool_class=ReadFile, - args=ReadFileArgs(path="/test/example.py"), + tool_name="read", + tool_class=Read, + args=ReadArgs(file_path="/test/example.py"), ) self._widget.update_event(full_event) diff --git a/tests/snapshots/test_ui_snapshot_trust_folder_dialog.py b/tests/snapshots/test_ui_snapshot_trust_folder_dialog.py new file mode 100644 index 0000000..ab0e3e3 --- /dev/null +++ b/tests/snapshots/test_ui_snapshot_trust_folder_dialog.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.snapshots.snap_compare import SnapCompare +from vibe.setup.trusted_folders.trust_folder_dialog import TrustFolderApp + + +@pytest.fixture(autouse=True) +def _pin_vibe_home(monkeypatch: pytest.MonkeyPatch) -> None: + # The dialog renders TRUSTED_FOLDERS_FILE.path in its footer. Pin the + # default so the path is stable across runs. Setting the VIBE_HOME env + # var is not enough: _get_vibe_home() calls Path.resolve(), which on + # macOS rewrites /home/user via the /System/Volumes/Data firmlink. + monkeypatch.delenv("VIBE_HOME", raising=False) + monkeypatch.setattr( + "vibe.core.paths._vibe_home._DEFAULT_VIBE_HOME", Path("/home/user/.vibe") + ) + + +_DIALOG_CSS = str( + Path(__file__).parents[2] + / "vibe" + / "setup" + / "trusted_folders" + / "trust_folder_dialog.tcss" +) + + +class TrustFolderDialogSnapshotApp(TrustFolderApp): + """cwd is itself the trust target (two-option dialog).""" + + CSS_PATH = _DIALOG_CSS + + def __init__(self) -> None: + super().__init__( + cwd=Path("/home/user/projects/my-project"), + repo_root=None, + detected_files=["AGENTS.md", ".vibe/"], + ) + + +class TrustFolderDialogWithRepoSnapshotApp(TrustFolderApp): + """cwd inside a git repo (three-option dialog).""" + + CSS_PATH = _DIALOG_CSS + + def __init__(self) -> None: + super().__init__( + cwd=Path("/home/user/projects/my-project/src/pkg"), + repo_root=Path("/home/user/projects/my-project"), + detected_files=["AGENTS.md"], + repo_detected_files=[".vibe/", "src/AGENTS.md"], + offer_repo_trust=True, + ) + + +class TrustFolderDialogUntrustedRepoSnapshotApp(TrustFolderApp): + """cwd inside a git repo that was previously marked untrusted.""" + + CSS_PATH = _DIALOG_CSS + + def __init__(self) -> None: + super().__init__( + cwd=Path("/home/user/projects/my-project/src/pkg"), + repo_root=Path("/home/user/projects/my-project"), + offer_repo_trust=False, + repo_explicitly_untrusted=True, + detected_files=["AGENTS.md"], + ) + + +class TrustFolderDialogManyFilesSnapshotApp(TrustFolderApp): + CSS_PATH = _DIALOG_CSS + + def __init__(self) -> None: + detected = [f"sub{i}/AGENTS.md" for i in range(20)] + [".vibe/", ".agents/"] + super().__init__( + cwd=Path("/home/user/projects/my-project"), + repo_root=None, + detected_files=detected, + ) + + +def test_snapshot_trust_folder_dialog(snap_compare: SnapCompare) -> None: + assert snap_compare( + "test_ui_snapshot_trust_folder_dialog.py:TrustFolderDialogSnapshotApp", + terminal_size=(80, 40), + ) + + +def test_snapshot_trust_folder_dialog_with_repo(snap_compare: SnapCompare) -> None: + assert snap_compare( + "test_ui_snapshot_trust_folder_dialog.py:TrustFolderDialogWithRepoSnapshotApp", + terminal_size=(80, 40), + ) + + +def test_snapshot_trust_folder_dialog_untrusted_repo(snap_compare: SnapCompare) -> None: + assert snap_compare( + "test_ui_snapshot_trust_folder_dialog.py:TrustFolderDialogUntrustedRepoSnapshotApp", + terminal_size=(80, 40), + ) + + +def test_snapshot_trust_folder_dialog_small_terminal(snap_compare: SnapCompare) -> None: + assert snap_compare( + "test_ui_snapshot_trust_folder_dialog.py:TrustFolderDialogSnapshotApp", + terminal_size=(80, 24), + ) + + +def test_snapshot_trust_folder_dialog_many_files(snap_compare: SnapCompare) -> None: + assert snap_compare( + "test_ui_snapshot_trust_folder_dialog.py:TrustFolderDialogManyFilesSnapshotApp", + terminal_size=(80, 40), + ) diff --git a/tests/test_agent_loop_images.py b/tests/test_agent_loop_images.py new file mode 100644 index 0000000..b6f1f35 --- /dev/null +++ b/tests/test_agent_loop_images.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.conftest import build_test_agent_loop, build_test_vibe_config +from tests.mock.utils import mock_llm_chunk +from tests.stubs.fake_backend import FakeBackend +from vibe.core.agent_loop import ImagesNotSupportedError +from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig +from vibe.core.types import Backend, ImageAttachment, LLMMessage, Role + +PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 + + +def _config_with_vision_flag(*, supports_images: bool) -> VibeConfig: + models = [ + ModelConfig( + name="mistral-vibe-cli-latest", + provider="mistral", + alias="devstral-latest", + supports_images=supports_images, + ) + ] + providers = [ + ProviderConfig( + name="mistral", + api_base="https://api.mistral.ai/v1", + api_key_env_var="MISTRAL_API_KEY", + backend=Backend.MISTRAL, + ) + ] + return build_test_vibe_config( + active_model="devstral-latest", models=models, providers=providers + ) + + +def _config_with_both_models() -> VibeConfig: + models = [ + ModelConfig( + name="vision-model", + provider="mistral", + alias="vision-alias", + supports_images=True, + ), + ModelConfig( + name="text-model", + provider="mistral", + alias="text-alias", + supports_images=False, + ), + ] + providers = [ + ProviderConfig( + name="mistral", + api_base="https://api.mistral.ai/v1", + api_key_env_var="MISTRAL_API_KEY", + backend=Backend.MISTRAL, + ) + ] + return build_test_vibe_config( + active_model="vision-alias", models=models, providers=providers + ) + + +@pytest.fixture() +def png_attachment(tmp_path: Path) -> ImageAttachment: + p = tmp_path / "x.png" + p.write_bytes(PNG_BYTES) + return ImageAttachment(path=p, alias="x.png", mime_type="image/png") + + +@pytest.mark.asyncio +async def test_act_raises_when_model_lacks_vision( + png_attachment: ImageAttachment, +) -> None: + config = _config_with_vision_flag(supports_images=False) + backend = FakeBackend([mock_llm_chunk(content="ok")]) + agent = build_test_agent_loop(config=config, backend=backend) + initial_message_count = len(agent.messages) + + with pytest.raises(ImagesNotSupportedError): + async for _ in agent.act("look", images=[png_attachment]): + pass + + assert backend.requests_extra_headers == [] # no LLM call was made + # Capability check runs *before* checkpoint creation and history mutation, + # so a rejected turn leaves no trace in either. + assert agent.rewind_manager.checkpoints == [] + assert len(agent.messages) == initial_message_count + + +@pytest.mark.asyncio +async def test_act_attaches_images_to_user_message( + png_attachment: ImageAttachment, +) -> None: + config = _config_with_vision_flag(supports_images=True) + backend = FakeBackend([mock_llm_chunk(content="ok")]) + agent = build_test_agent_loop(config=config, backend=backend) + + [_ async for _ in agent.act("look", images=[png_attachment])] + + user_msgs = [m for m in agent.messages if m.role.value == "user"] + assert len(user_msgs) == 1 + assert user_msgs[0].images == [png_attachment] + + +def _seed_history_image(agent, png_attachment: ImageAttachment) -> None: + agent.messages.append( + LLMMessage(role=Role.user, content="earlier", images=[png_attachment]) + ) + agent.messages.append(LLMMessage(role=Role.assistant, content="seen")) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("enable_streaming", [False, True]) +async def test_history_images_stripped_from_backend_payload_on_non_vision_model( + png_attachment: ImageAttachment, enable_streaming: bool +) -> None: + config = _config_with_both_models() + backend = FakeBackend([mock_llm_chunk(content="ok")]) + agent = build_test_agent_loop( + config=config, backend=backend, enable_streaming=enable_streaming + ) + _seed_history_image(agent, png_attachment) + agent.config.active_model = "text-alias" + + [_ async for _ in agent.act("hi")] + + sent_messages = backend.requests_messages[-1] + assert all(m.images is None for m in sent_messages) + # source-of-truth history is not mutated + assert any(m.images == [png_attachment] for m in agent.messages) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("enable_streaming", [False, True]) +async def test_switch_back_to_vision_model_restores_images_in_payload( + png_attachment: ImageAttachment, enable_streaming: bool +) -> None: + config = _config_with_both_models() + backend = FakeBackend([ + [mock_llm_chunk(content="a")], + [mock_llm_chunk(content="b")], + ]) + agent = build_test_agent_loop( + config=config, backend=backend, enable_streaming=enable_streaming + ) + _seed_history_image(agent, png_attachment) + + agent.config.active_model = "text-alias" + [_ async for _ in agent.act("hi")] + agent.config.active_model = "vision-alias" + [_ async for _ in agent.act("hi again")] + + sent_messages = backend.requests_messages[-1] + assert any(m.images == [png_attachment] for m in sent_messages) + + +def test_count_history_images_unsupported_by_active_model( + png_attachment: ImageAttachment, +) -> None: + config = _config_with_both_models() + agent = build_test_agent_loop(config=config) + + assert agent.count_history_images_unsupported_by_active_model() == 0 + + _seed_history_image(agent, png_attachment) + # active model still supports images + assert agent.count_history_images_unsupported_by_active_model() == 0 + + agent.config.active_model = "text-alias" + assert agent.count_history_images_unsupported_by_active_model() == 1 + + agent.messages.append( + LLMMessage(role=Role.user, content="more", images=[png_attachment]) + ) + assert agent.count_history_images_unsupported_by_active_model() == 2 + + +@pytest.mark.asyncio +async def test_new_images_with_non_vision_model_still_raises( + png_attachment: ImageAttachment, +) -> None: + config = _config_with_both_models() + backend = FakeBackend([mock_llm_chunk(content="ok")]) + agent = build_test_agent_loop(config=config, backend=backend) + agent.config.active_model = "text-alias" + + with pytest.raises(ImagesNotSupportedError): + async for _ in agent.act("look", images=[png_attachment]): + pass diff --git a/tests/test_agent_override_resolve_permission.py b/tests/test_agent_override_resolve_permission.py index 578530b..b13f805 100644 --- a/tests/test_agent_override_resolve_permission.py +++ b/tests/test_agent_override_resolve_permission.py @@ -43,16 +43,14 @@ class TestPlanAgentWriteFileResolvePermission: assert ctx is not None assert ctx.permission == ToolPermission.ALWAYS - def test_search_replace_to_non_plan_path_denied_in_plan_mode(self) -> None: + def test_edit_to_non_plan_path_denied_in_plan_mode(self) -> None: config = build_test_vibe_config() agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN) - tool = agent.tool_manager.get("search_replace") - from vibe.core.tools.builtins.search_replace import SearchReplaceArgs + tool = agent.tool_manager.get("edit") + from vibe.core.tools.builtins.edit import EditArgs - args = SearchReplaceArgs( - file_path="/some/file.py", content="<<<< SEARCH\na\n====\nb\n>>>> REPLACE" - ) + args = EditArgs(file_path="/some/file.py", old_string="a", new_string="b") ctx = tool.resolve_permission(args) @@ -61,7 +59,7 @@ class TestPlanAgentWriteFileResolvePermission: class TestAcceptEditsAgentResolvePermission: - """Accept-edits agent sets write_file/search_replace to ALWAYS. + """Accept-edits agent sets write_file/edit to ALWAYS. resolve_permission must reflect this. """ diff --git a/tests/test_agent_tool_call.py b/tests/test_agent_tool_call.py index 305372e..6d97e3f 100644 --- a/tests/test_agent_tool_call.py +++ b/tests/test_agent_tool_call.py @@ -458,7 +458,7 @@ async def test_tool_call_can_be_interrupted() -> None: ]), ) # no dependency injection available => monkey patch - agent_loop.tool_manager._available["stub_tool"] = FakeTool + agent_loop.tool_manager._all_tools["stub_tool"] = FakeTool stub_tool_instance = agent_loop.tool_manager.get("stub_tool") assert isinstance(stub_tool_instance, FakeTool) stub_tool_instance._exception_to_raise = asyncio.CancelledError() diff --git a/tests/test_agents.py b/tests/test_agents.py index e979121..1641b98 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -81,7 +81,7 @@ class TestDeepMerge: def test_lists_are_overridden_not_merged(self) -> None: """Lists should be replaced entirely, not merged element-by-element.""" - base = {"tools": ["read_file", "grep", "bash"]} + base = {"tools": ["read", "grep", "bash"]} override = {"tools": ["write_file"]} result = _deep_merge(base, override) assert result == {"tools": ["write_file"]} @@ -200,7 +200,7 @@ class TestAgentApplyToConfig: assert "ask_user_question" not in result.enabled_tools assert "grep" in result.enabled_tools - assert "read_file" in result.enabled_tools + assert "read" in result.enabled_tools assert "task" in result.enabled_tools def test_base_disabled_tools_filter_supports_glob_patterns(self) -> None: @@ -349,20 +349,20 @@ class TestAgentProfileOverrides: assert "tools" in overrides tools = overrides["tools"] assert "write_file" in tools - assert "search_replace" in tools + assert "edit" in tools assert tools["write_file"]["permission"] == "never" - assert tools["search_replace"]["permission"] == "never" + assert tools["edit"]["permission"] == "never" assert len(tools["write_file"]["allowlist"]) > 0 - assert len(tools["search_replace"]["allowlist"]) > 0 + assert len(tools["edit"]["allowlist"]) > 0 def test_accept_edits_agent_sets_tool_permissions(self) -> None: overrides = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].overrides assert "tools" in overrides tools_config = overrides["tools"] assert "write_file" in tools_config - assert "search_replace" in tools_config + assert "edit" in tools_config assert tools_config["write_file"]["permission"] == "always" - assert tools_config["search_replace"]["permission"] == "always" + assert tools_config["edit"]["permission"] == "always" class TestAgentManagerCycling: @@ -460,9 +460,9 @@ class TestAgentSwitchAgent: plan_tool_names = set(agent.tool_manager.available_tools.keys()) # Plan mode now has all tools available but with restricted permissions assert "write_file" in plan_tool_names - assert "search_replace" in plan_tool_names + assert "edit" in plan_tool_names assert "grep" in plan_tool_names - assert "read_file" in plan_tool_names + assert "read" in plan_tool_names assert agent.agent_profile.name == BuiltinAgentName.PLAN # Verify write tools have "never" base permission @@ -522,9 +522,9 @@ class TestAcceptEditsAgent: overrides = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].overrides assert overrides["tools"]["write_file"]["permission"] == "always" - def test_accept_edits_config_sets_search_replace_always(self) -> None: + def test_accept_edits_config_sets_edit_always(self) -> None: overrides = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS].overrides - assert overrides["tools"]["search_replace"]["permission"] == "always" + assert overrides["tools"]["edit"]["permission"] == "always" @pytest.mark.asyncio async def test_accept_edits_agent_auto_approves_write_file(self) -> None: @@ -571,18 +571,18 @@ class TestPlanAgentToolRestriction: # Plan mode now has all tools available assert "grep" in tool_names - assert "read_file" in tool_names + assert "read" in tool_names assert "write_file" in tool_names - assert "search_replace" in tool_names + assert "edit" in tool_names # But write tools have restricted permissions write_config = agent.tool_manager.get_tool_config("write_file") assert write_config.permission == ToolPermission.NEVER assert len(write_config.allowlist) > 0 - sr_config = agent.tool_manager.get_tool_config("search_replace") - assert sr_config.permission == ToolPermission.NEVER - assert len(sr_config.allowlist) > 0 + edit_config = agent.tool_manager.get_tool_config("edit") + assert edit_config.permission == ToolPermission.NEVER + assert len(edit_config.allowlist) > 0 class TestAgentManagerFiltering: diff --git a/tests/test_permission_store.py b/tests/test_permission_store.py index 75c95e6..f921255 100644 --- a/tests/test_permission_store.py +++ b/tests/test_permission_store.py @@ -62,7 +62,7 @@ class TestPermissionStore: label="npm install *", ) - assert not store.covers("read_file", rp) + assert not store.covers("read", rp) def test_tool_permission_round_trip(self): store = PermissionStore() diff --git a/tests/tools/test_connectors.py b/tests/tools/test_connectors.py index dae9784..408aa6e 100644 --- a/tests/tools/test_connectors.py +++ b/tests/tools/test_connectors.py @@ -12,6 +12,7 @@ from tests.stubs.fake_connector_registry import FakeConnectorRegistry from tests.stubs.fake_mcp_registry import FakeMCPRegistry from vibe.core.config import ConnectorConfig, VibeConfig from vibe.core.tools.base import BaseToolConfig, ToolError +from vibe.core.tools.connectors import compute_connector_counts from vibe.core.tools.connectors.connector_registry import ( ConnectorAuthAction, ConnectorRegistry, @@ -882,3 +883,84 @@ class TestAuthActionablediscovery: assert "linear" in registry.get_connector_names() assert registry.get_auth_action("linear") == ConnectorAuthAction.OAUTH assert registry.get_connector_id("linear") == "c-1" + + +# --------------------------------------------------------------------------- +# Banner connector counts +# --------------------------------------------------------------------------- + + +class TestComputeConnectorCounts: + def test_no_registry(self) -> None: + assert compute_connector_counts(build_test_vibe_config(), None) == (0, 0) + + def test_empty_registry(self) -> None: + registry = FakeConnectorRegistry() + assert compute_connector_counts(build_test_vibe_config(), registry) == (0, 0) + + def test_no_config_entry_is_disabled_by_default(self) -> None: + # Mirrors ToolManager: connectors without an explicit ConnectorConfig + # entry are disabled (their tools are not registered). + registry = FakeConnectorRegistry({ + "alpha": [RemoteTool(name="search")], + "beta": [RemoteTool(name="list")], + }) + assert compute_connector_counts(build_test_vibe_config(), registry) == (0, 2) + + def test_explicitly_enabled_and_connected(self) -> None: + registry = FakeConnectorRegistry({ + "alpha": [RemoteTool(name="search")], + "beta": [RemoteTool(name="list")], + }) + config = build_test_vibe_config( + connectors=[ + ConnectorConfig(name="alpha", disabled=False), + ConnectorConfig(name="beta", disabled=False), + ] + ) + assert compute_connector_counts(config, registry) == (2, 2) + + def test_auth_pending_not_counted(self) -> None: + # Empty tool list → FakeConnectorRegistry marks as not connected. + registry = FakeConnectorRegistry( + {"alpha": [RemoteTool(name="search")], "needs_auth": []}, + auth_actions={"needs_auth": ConnectorAuthAction.OAUTH}, + ) + config = build_test_vibe_config( + connectors=[ + ConnectorConfig(name="alpha", disabled=False), + ConnectorConfig(name="needs_auth", disabled=False), + ] + ) + assert compute_connector_counts(config, registry) == (1, 2) + + def test_disabled_not_counted(self) -> None: + registry = FakeConnectorRegistry({ + "alpha": [RemoteTool(name="search")], + "beta": [RemoteTool(name="list")], + }) + config = build_test_vibe_config( + connectors=[ + ConnectorConfig(name="alpha", disabled=False), + ConnectorConfig(name="beta", disabled=True), + ] + ) + assert compute_connector_counts(config, registry) == (1, 2) + + def test_disabled_and_pending_combined(self) -> None: + registry = FakeConnectorRegistry( + { + "alpha": [RemoteTool(name="search")], + "beta": [RemoteTool(name="list")], + "needs_auth": [], + }, + auth_actions={"needs_auth": ConnectorAuthAction.OAUTH}, + ) + config = build_test_vibe_config( + connectors=[ + ConnectorConfig(name="alpha", disabled=False), + ConnectorConfig(name="beta", disabled=True), + ConnectorConfig(name="needs_auth", disabled=False), + ] + ) + assert compute_connector_counts(config, registry) == (1, 3) diff --git a/tests/tools/test_granular_permissions.py b/tests/tools/test_granular_permissions.py index f6d91fe..d78dfc6 100644 --- a/tests/tools/test_granular_permissions.py +++ b/tests/tools/test_granular_permissions.py @@ -11,18 +11,9 @@ from vibe.core.tools.builtins.bash import ( BashToolConfig, _collect_outside_dirs, ) +from vibe.core.tools.builtins.edit import Edit, EditArgs, EditConfig from vibe.core.tools.builtins.grep import Grep, GrepArgs, GrepToolConfig -from vibe.core.tools.builtins.read_file import ( - ReadFile, - ReadFileArgs, - ReadFileState, - ReadFileToolConfig, -) -from vibe.core.tools.builtins.search_replace import ( - SearchReplace, - SearchReplaceArgs, - SearchReplaceConfig, -) +from vibe.core.tools.builtins.read import Read, ReadArgs, ReadConfig, ReadState from vibe.core.tools.builtins.webfetch import WebFetch, WebFetchArgs, WebFetchConfig from vibe.core.tools.builtins.write_file import ( WriteFile, @@ -262,24 +253,24 @@ class TestBashGranularPermissions: assert len(outside) >= 1 -class TestReadFileGranularPermissions: +class TestReadGranularPermissions: @pytest.fixture(autouse=True) def _setup(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) self.workdir = tmp_path - def _read_file(self, **kwargs): - config = ReadFileToolConfig(**kwargs) - return ReadFile(config_getter=lambda: config, state=ReadFileState()) + def _read(self, **kwargs): + config = ReadConfig(**kwargs) + return Read(config_getter=lambda: config, state=ReadState()) def test_in_workdir_normal_file_returns_none(self): (self.workdir / "test.py").touch() - tool = self._read_file() - assert tool.resolve_permission(ReadFileArgs(path="test.py")) is None + tool = self._read() + assert tool.resolve_permission(ReadArgs(file_path="test.py")) is None def test_outside_workdir_returns_permission_context(self): - tool = self._read_file() - result = tool.resolve_permission(ReadFileArgs(path="/tmp/file.txt")) + tool = self._read() + result = tool.resolve_permission(ReadArgs(file_path="/tmp/file.txt")) assert isinstance(result, PermissionContext) assert result.permission is ToolPermission.ASK outside = [ @@ -291,8 +282,8 @@ class TestReadFileGranularPermissions: def test_sensitive_env_file_returns_permission_context(self): (self.workdir / ".env").touch() - tool = self._read_file() - result = tool.resolve_permission(ReadFileArgs(path=".env")) + tool = self._read() + result = tool.resolve_permission(ReadArgs(file_path=".env")) assert isinstance(result, PermissionContext) assert result.permission is ToolPermission.ASK sensitive = [ @@ -305,8 +296,8 @@ class TestReadFileGranularPermissions: def test_sensitive_env_local_file(self): (self.workdir / ".env.local").touch() - tool = self._read_file() - result = tool.resolve_permission(ReadFileArgs(path=".env.local")) + tool = self._read() + result = tool.resolve_permission(ReadArgs(file_path=".env.local")) assert isinstance(result, PermissionContext) sensitive = [ rp @@ -316,31 +307,31 @@ class TestReadFileGranularPermissions: assert len(sensitive) == 1 def test_sensitive_outside_both_permissions(self): - tool = self._read_file() - result = tool.resolve_permission(ReadFileArgs(path="/tmp/.env")) + tool = self._read() + result = tool.resolve_permission(ReadArgs(file_path="/tmp/.env")) assert isinstance(result, PermissionContext) scopes = {rp.scope for rp in result.required_permissions} assert PermissionScope.FILE_PATTERN in scopes assert PermissionScope.OUTSIDE_DIRECTORY in scopes def test_denylisted_returns_never(self): - tool = self._read_file(denylist=["*/secret*"]) - result = tool.resolve_permission(ReadFileArgs(path="secret.key")) + tool = self._read(denylist=["*/secret*"]) + result = tool.resolve_permission(ReadArgs(file_path="secret.key")) assert isinstance(result, PermissionContext) assert result.permission is ToolPermission.NEVER def test_allowlisted_returns_always(self): - tool = self._read_file(allowlist=["*/README*"]) + tool = self._read(allowlist=["*/README*"]) result = tool.resolve_permission( - ReadFileArgs(path=str(self.workdir / "README.md")) + ReadArgs(file_path=str(self.workdir / "README.md")) ) assert isinstance(result, PermissionContext) assert result.permission is ToolPermission.ALWAYS def test_custom_sensitive_patterns(self): (self.workdir / "credentials.json").touch() - tool = self._read_file(sensitive_patterns=["*/credentials*"]) - result = tool.resolve_permission(ReadFileArgs(path="credentials.json")) + tool = self._read(sensitive_patterns=["*/credentials*"]) + result = tool.resolve_permission(ReadArgs(file_path="credentials.json")) assert isinstance(result, PermissionContext) @@ -371,23 +362,21 @@ class TestWriteFileGranularPermissions: def test_sensitive_env_file_asks(self): (self.workdir / ".env").touch() tool = self._write_file() - result = tool.resolve_permission( - WriteFileArgs(path=".env", content="x", overwrite=True) - ) + result = tool.resolve_permission(WriteFileArgs(path=".env", content="x")) assert isinstance(result, PermissionContext) assert result.permission is ToolPermission.ASK -class TestSearchReplaceGranularPermissions: +class TestEditGranularPermissions: @pytest.fixture(autouse=True) def _setup(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) def test_outside_workdir_returns_permission_context(self): - config = SearchReplaceConfig() - tool = SearchReplace(config_getter=lambda: config, state=BaseToolState()) + config = EditConfig() + tool = Edit(config_getter=lambda: config, state=BaseToolState()) result = tool.resolve_permission( - SearchReplaceArgs(file_path="/tmp/file.py", content="x") + EditArgs(file_path="/tmp/file.py", old_string="a", new_string="b") ) assert isinstance(result, PermissionContext) assert result.permission is ToolPermission.ASK @@ -546,10 +535,10 @@ class TestApprovalFlowSimulation: uncovered = [rp for rp in cmd_perms if not self._is_covered("bash", rp, rules)] assert len(uncovered) == 1 - def test_read_file_sensitive_approved_covers_subsequent(self): + def test_read_sensitive_approved_covers_subsequent(self): rules = [ ApprovedRule( - tool_name="read_file", + tool_name="read", scope=PermissionScope.FILE_PATTERN, session_pattern="*", ) @@ -558,9 +547,9 @@ class TestApprovalFlowSimulation: scope=PermissionScope.FILE_PATTERN, invocation_pattern=".env.production", session_pattern="*", - label="reading sensitive files (read_file)", + label="reading sensitive files (read)", ) - assert self._is_covered("read_file", rp, rules) + assert self._is_covered("read", rp, rules) def test_different_tool_rule_doesnt_cover(self): rules = [ diff --git a/tests/tools/test_manager_get_tool_config.py b/tests/tools/test_manager_get_tool_config.py index 567eb2e..00e0430 100644 --- a/tests/tools/test_manager_get_tool_config.py +++ b/tests/tools/test_manager_get_tool_config.py @@ -6,7 +6,7 @@ import pytest from tests.conftest import build_test_vibe_config from vibe.core.tools.base import BaseToolConfig, ToolPermission -from vibe.core.tools.manager import ToolManager +from vibe.core.tools.manager import NoSuchToolError, ToolManager @pytest.fixture @@ -75,17 +75,16 @@ def test_partial_override_preserves_tool_defaults(): vibe_config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, - tools={"read_file": {"max_read_bytes": 32000}}, + tools={"read": {"sensitive_patterns": ["**/*.key"]}}, ) manager = ToolManager(lambda: vibe_config) - config = manager.get_tool_config("read_file") + config = manager.get_tool_config("read") assert ( config.permission == ToolPermission.ALWAYS - ) # ReadFileToolConfig default, not BaseToolConfig.ASK - assert config.sensitive_patterns == ["**/.env", "**/.env.*"] # type: ignore[attr-defined] - assert config.max_read_bytes == 32000 # type: ignore[attr-defined] + ) # ReadConfig default, not BaseToolConfig.ASK + assert config.sensitive_patterns == ["**/*.key"] # type: ignore[attr-defined] class TestToolManagerFiltering: @@ -98,10 +97,10 @@ class TestToolManagerFiltering: manager = ToolManager(lambda: vibe_config) tools = manager.available_tools - assert len(tools) < len(manager._available) + assert len(tools) < len(manager._all_tools) assert "bash" in tools assert "grep" in tools - assert "read_file" not in tools + assert "read" not in tools assert "write_file" not in tools def test_disabled_tools_excludes_disabled(self): @@ -113,11 +112,11 @@ class TestToolManagerFiltering: manager = ToolManager(lambda: vibe_config) tools = manager.available_tools - assert len(tools) < len(manager._available) + assert len(tools) < len(manager._all_tools) assert "bash" not in tools assert "write_file" not in tools assert "grep" in tools - assert "read_file" in tools + assert "read" in tools def test_enabled_tools_takes_precedence_over_disabled(self): vibe_config = build_test_vibe_config( @@ -136,12 +135,11 @@ class TestToolManagerFiltering: vibe_config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, - disabled_tools=["*_file"], # Matches read_file, write_file + disabled_tools=["write_*"], # Matches write_file ) manager = ToolManager(lambda: vibe_config) tools = manager.available_tools - assert "read_file" not in tools assert "write_file" not in tools assert "bash" in tools assert "grep" in tools @@ -159,6 +157,18 @@ class TestToolManagerFiltering: assert "bash" in tools assert "grep" in tools + def test_get_raises_for_disabled_tool(self): + vibe_config = build_test_vibe_config( + system_prompt_id="tests", + include_project_context=False, + disabled_tools=["bash"], + ) + manager = ToolManager(lambda: vibe_config) + + assert "bash" not in manager.available_tools + with pytest.raises(NoSuchToolError): + manager.get("bash") + def test_case_insensitive_matching(self): vibe_config = build_test_vibe_config( system_prompt_id="tests", @@ -180,7 +190,7 @@ class TestToolManagerFiltering: tools = manager.available_tools assert "bash" in tools assert "grep" in tools - assert "read_file" in tools + assert "read" in tools def test_tool_paths_with_file_and_directory(self, tmp_path: Path): """Should handle a mix of file and directory paths in tool_paths.""" diff --git a/tests/tools/test_scratchpad_permissions.py b/tests/tools/test_scratchpad_permissions.py index f73f29c..cd48f65 100644 --- a/tests/tools/test_scratchpad_permissions.py +++ b/tests/tools/test_scratchpad_permissions.py @@ -11,12 +11,7 @@ from vibe.core.tools.builtins.bash import ( BashToolConfig, _collect_outside_dirs, ) -from vibe.core.tools.builtins.read_file import ( - ReadFile, - ReadFileArgs, - ReadFileState, - ReadFileToolConfig, -) +from vibe.core.tools.builtins.read import Read, ReadArgs, ReadConfig, ReadState from vibe.core.tools.builtins.write_file import ( WriteFile, WriteFileArgs, @@ -41,13 +36,11 @@ class TestFileToolScratchpadPermissions: assert isinstance(result, PermissionContext) assert result.permission is ToolPermission.ALWAYS - def test_read_file_scratchpad_always_allowed(self): + def test_read_scratchpad_always_allowed(self): sp = scratchpad_mod.get_scratchpad_dir("test-session") assert sp is not None - tool = ReadFile( - config_getter=lambda: ReadFileToolConfig(), state=ReadFileState() - ) - result = tool.resolve_permission(ReadFileArgs(path=str(sp / "notes.txt"))) + tool = Read(config_getter=lambda: ReadConfig(), state=ReadState()) + result = tool.resolve_permission(ReadArgs(file_path=str(sp / "notes.txt"))) assert isinstance(result, PermissionContext) assert result.permission is ToolPermission.ALWAYS diff --git a/tests/tools/test_ui_bash_execution.py b/tests/tools/test_ui_bash_execution.py index 3f53aa8..11a2620 100644 --- a/tests/tools/test_ui_bash_execution.py +++ b/tests/tools/test_ui_bash_execution.py @@ -85,7 +85,7 @@ async def test_ui_shows_success_in_case_of_zero_code(vibe_app: VibeApp) -> None: async def test_ui_shows_failure_in_case_of_non_zero_code(vibe_app: VibeApp) -> None: async with vibe_app.run_test() as pilot: chat_input = vibe_app.query_one(ChatInputContainer) - chat_input.value = "!bash -lc 'exit 7'" + chat_input.value = "!bash -c 'exit 7'" await pilot.press("enter") message = await _wait_for_bash_output_message(vibe_app, pilot) @@ -127,7 +127,7 @@ async def test_ui_handles_utf8_output(vibe_app: VibeApp) -> None: async def test_ui_handles_non_utf8_stderr(vibe_app: VibeApp) -> None: async with vibe_app.run_test() as pilot: chat_input = vibe_app.query_one(ChatInputContainer) - chat_input.value = "!bash -lc \"printf '\\\\xff\\\\xfe' 1>&2\"" + chat_input.value = "!bash -c \"printf '\\\\xff\\\\xfe' 1>&2\"" await pilot.press("enter") message = await _wait_for_bash_output_message(vibe_app, pilot) @@ -198,7 +198,7 @@ async def test_ui_streams_output_incrementally(vibe_app: VibeApp) -> None: async with vibe_app.run_test() as pilot: chat_input = vibe_app.query_one(ChatInputContainer) # print lines with a small delay so streaming has a chance to show partial output - chat_input.value = "!bash -lc 'echo first; echo second'" + chat_input.value = "!bash -c 'echo first; echo second'" await pilot.press("enter") message = await _wait_for_bash_output_message(vibe_app, pilot) diff --git a/uv.lock b/uv.lock index 389526a..befca6d 100644 --- a/uv.lock +++ b/uv.lock @@ -3,24 +3,19 @@ revision = 3 requires-python = ">=3.12" [options] -exclude-newer = "2026-05-22T14:05:58.077106Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P7D" -[options.exclude-newer-package] -mistralai = false -textual = false -agent-client-protocol = false - [[package]] name = "agent-client-protocol" -version = "0.9.0" +version = "0.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/13/3b893421369767e7043cc115d6ef0df417c298b84563be3a12df0416158d/agent_client_protocol-0.9.0.tar.gz", hash = "sha256:f744c48ab9af0f0b4452e5ab5498d61bcab97c26dbe7d6feec5fd36de49be30b", size = 71853, upload-time = "2026-03-26T01:21:00.379Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/a0/3b96cd8374725c69bc3dae9fcc2082f3f6cafec1be35d24d7af0f8c3265f/agent_client_protocol-0.10.1.tar.gz", hash = "sha256:355c65ca19f0568344aafc2c1552b7066a8fc491df23ab28e7e253c6c9a85a25", size = 81924, upload-time = "2026-05-24T18:46:44.444Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/ed/c284543c08aa443a4ef2c8bd120be51da8433dd174c01749b5d87c333f22/agent_client_protocol-0.9.0-py3-none-any.whl", hash = "sha256:06911500b51d8cb69112544e2be01fc5e7db39ef88fecbc3848c5c6f194798ee", size = 56850, upload-time = "2026-03-26T01:20:59.252Z" }, + { url = "https://files.pythonhosted.org/packages/7b/18/d8c7ff337cf621ea79a84006a7252ff057bfb5767549bb102cc6649f4ec2/agent_client_protocol-0.10.1-py3-none-any.whl", hash = "sha256:a03d3198f4d772f2e0ec012c00ac1cce131b4710220a3dc9fae3c991d047c750", size = 65401, upload-time = "2026-05-24T18:46:43.202Z" }, ] [[package]] @@ -477,6 +472,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] +[[package]] +name = "humanize" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/66/a3921783d54be8a6870ac4ccffcd15c4dc0dd7fcce51c6d63b8c63935276/humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10", size = 83599, upload-time = "2025-12-20T20:16:13.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" }, +] + [[package]] name = "id" version = "1.6.1" @@ -821,7 +825,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.13.0" +version = "2.14.0" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, @@ -846,6 +850,7 @@ dependencies = [ { name = "httpcore" }, { name = "httpx" }, { name = "httpx-sse" }, + { name = "humanize" }, { name = "idna" }, { name = "importlib-metadata" }, { name = "jaraco-classes" }, @@ -923,65 +928,13 @@ dependencies = [ [package.dev-dependencies] build = [ { name = "altgraph" }, - { name = "anyio" }, - { name = "certifi" }, - { name = "cffi", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'" }, - { name = "cfgv" }, - { name = "charset-normalizer" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "cryptography", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'" }, - { name = "distlib" }, - { name = "docutils" }, - { name = "execnet" }, - { name = "filelock" }, - { name = "h11" }, - { name = "httpcore" }, - { name = "httpx" }, - { name = "id" }, - { name = "identify" }, - { name = "idna" }, - { name = "iniconfig" }, - { name = "jaraco-classes", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, - { name = "jaraco-context", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, - { name = "jaraco-functools", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, - { name = "jeepney", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'" }, - { name = "jinja2" }, - { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, - { name = "linkify-it-py" }, { name = "macholib", marker = "sys_platform == 'darwin'" }, - { name = "markdown-it-py" }, - { name = "markupsafe" }, - { name = "mdit-py-plugins" }, - { name = "mdurl" }, - { name = "more-itertools", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, - { name = "nh3" }, - { name = "nodeenv" }, { name = "packaging" }, { name = "pefile", marker = "sys_platform == 'win32'" }, - { name = "platformdirs" }, - { name = "pluggy" }, - { name = "pycparser", marker = "implementation_name != 'PyPy' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'" }, - { name = "pygments" }, { name = "pyinstaller" }, { name = "pyinstaller-hooks-contrib" }, - { name = "pyinstrument" }, - { name = "python-discovery" }, { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, - { name = "pyyaml" }, - { name = "readme-renderer" }, - { name = "requests" }, - { name = "requests-toolbelt" }, - { name = "rfc3986" }, - { name = "rich" }, - { name = "secretstorage", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'" }, { name = "setuptools" }, - { name = "syrupy" }, - { name = "textual" }, - { name = "truststore" }, - { name = "typing-extensions" }, - { name = "uc-micro-py" }, - { name = "urllib3" }, - { name = "virtualenv" }, ] dev = [ { name = "debugpy" }, @@ -1003,7 +956,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "agent-client-protocol", specifier = "==0.9.0" }, + { name = "agent-client-protocol", specifier = "==0.10.1" }, { name = "annotated-types", specifier = "==0.7.0" }, { name = "anyio", specifier = "==4.13.0" }, { name = "attrs", specifier = "==26.1.0" }, @@ -1025,6 +978,7 @@ requires-dist = [ { name = "httpcore", specifier = "==1.0.9" }, { name = "httpx", specifier = "==0.28.1" }, { name = "httpx-sse", specifier = "==0.4.3" }, + { name = "humanize", specifier = "==4.15.0" }, { name = "idna", specifier = "==3.13" }, { name = "importlib-metadata", specifier = "==8.7.1" }, { name = "jaraco-classes", specifier = "==3.4.0" }, @@ -1102,65 +1056,13 @@ requires-dist = [ [package.metadata.requires-dev] build = [ { name = "altgraph", specifier = "==0.17.5" }, - { name = "anyio", specifier = "==4.13.0" }, - { name = "certifi", specifier = "==2026.4.22" }, - { name = "cffi", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'", specifier = "==2.0.0" }, - { name = "cfgv", specifier = "==3.5.0" }, - { name = "charset-normalizer", specifier = "==3.4.7" }, - { name = "colorama", marker = "sys_platform == 'win32'", specifier = "==0.4.6" }, - { name = "cryptography", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", specifier = "==47.0.0" }, - { name = "distlib", specifier = "==0.4.0" }, - { name = "docutils", specifier = "==0.22.4" }, - { name = "execnet", specifier = "==2.1.2" }, - { name = "filelock", specifier = "==3.29.0" }, - { name = "h11", specifier = "==0.16.0" }, - { name = "httpcore", specifier = "==1.0.9" }, - { name = "httpx", specifier = "==0.28.1" }, - { name = "id", specifier = "==1.6.1" }, - { name = "identify", specifier = "==2.6.19" }, - { name = "idna", specifier = "==3.13" }, - { name = "iniconfig", specifier = "==2.3.0" }, - { name = "jaraco-classes", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==3.4.0" }, - { name = "jaraco-context", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==6.1.2" }, - { name = "jaraco-functools", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==4.4.0" }, - { name = "jeepney", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", specifier = "==0.9.0" }, - { name = "jinja2", specifier = "==3.1.6" }, - { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==25.7.0" }, - { name = "linkify-it-py", specifier = "==2.1.0" }, { name = "macholib", marker = "sys_platform == 'darwin'", specifier = "==1.16.4" }, - { name = "markdown-it-py", specifier = "==4.0.0" }, - { name = "markupsafe", specifier = "==3.0.3" }, - { name = "mdit-py-plugins", specifier = "==0.5.0" }, - { name = "mdurl", specifier = "==0.1.2" }, - { name = "more-itertools", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==11.0.2" }, - { name = "nh3", specifier = "==0.3.5" }, - { name = "nodeenv", specifier = "==1.10.0" }, { name = "packaging", specifier = "==26.2" }, { name = "pefile", marker = "sys_platform == 'win32'", specifier = "==2024.8.26" }, - { name = "platformdirs", specifier = "==4.9.6" }, - { name = "pluggy", specifier = "==1.6.0" }, - { name = "pycparser", marker = "implementation_name != 'PyPy' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'", specifier = "==3.0" }, - { name = "pygments", specifier = "==2.20.0" }, { name = "pyinstaller", specifier = "==6.20.0" }, { name = "pyinstaller-hooks-contrib", specifier = "==2026.4" }, - { name = "pyinstrument", specifier = "==5.1.2" }, - { name = "python-discovery", specifier = "==1.2.2" }, { name = "pywin32-ctypes", marker = "sys_platform == 'win32'", specifier = "==0.2.3" }, - { name = "pyyaml", specifier = "==6.0.3" }, - { name = "readme-renderer", specifier = "==44.0" }, - { name = "requests", specifier = "==2.33.1" }, - { name = "requests-toolbelt", specifier = "==1.0.0" }, - { name = "rfc3986", specifier = "==2.0.0" }, - { name = "rich", specifier = "==15.0.0" }, - { name = "secretstorage", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", specifier = "==3.5.0" }, { name = "setuptools", specifier = "==82.0.1" }, - { name = "syrupy", specifier = "==4.8.0" }, - { name = "textual", specifier = "==8.2.7" }, - { name = "truststore", specifier = "==0.10.4" }, - { name = "typing-extensions", specifier = "==4.15.0" }, - { name = "uc-micro-py", specifier = "==2.0.0" }, - { name = "urllib3", specifier = "==2.6.3" }, - { name = "virtualenv", specifier = "==21.2.4" }, ] dev = [ { name = "debugpy", specifier = ">=1.8.19" }, @@ -1173,7 +1075,7 @@ dev = [ { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "respx", specifier = ">=0.22.0" }, - { name = "ruff", specifier = ">=0.14.5" }, + { name = "ruff", specifier = ">=0.15.13" }, { name = "tomlkit", specifier = ">=0.13.0" }, { name = "twine", specifier = ">=5.0.0" }, { name = "typos", specifier = ">=1.34.0" }, @@ -2037,27 +1939,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.12" +version = "0.15.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, ] [[package]] diff --git a/vibe.spec b/vibe.spec new file mode 100644 index 0000000..266d979 --- /dev/null +++ b/vibe.spec @@ -0,0 +1,72 @@ +# -*- mode: python ; coding: utf-8 -*- +# Onedir build for vibe — no per-launch extraction overhead. +# Build: uv run --group build pyinstaller vibe.spec +# Output: dist/vibe-dir/vibe (+ dist/vibe-dir/_internal/) + +from PyInstaller.utils.hooks import collect_all, collect_data_files, collect_submodules + +_core_builtins_datas, core_builtins_binaries, core_builtins_hidden_imports = ( + collect_all("vibe.core.tools.builtins") +) + +# rich lazily loads Unicode width tables via importlib.import_module() at runtime, +# which PyInstaller's static analysis cannot discover. +hidden_imports = ["truststore"] + collect_submodules("rich._unicode_data") +for item in core_builtins_hidden_imports: + if isinstance(item, str): + hidden_imports.append(item) + +binaries = core_builtins_binaries + +datas = collect_data_files("vibe", includes=["**/*.md", "**/*.tcss"]) +datas += [("vibe/core/tools/builtins/*.py", "vibe/core/tools/builtins")] +# Built-in skills are read from source files at runtime, so collect_data_files +# must be allowed to include .py files here. By default it filters .py/.pyc out. +datas += collect_data_files( + "vibe.core.skills.builtins", includes=["*.py"], include_py_files=True +) + +a = Analysis( + ["vibe/cli/entrypoint.py"], + pathex=[], + binaries=binaries, + datas=datas, + hiddenimports=hidden_imports, + hookspath=[], + hooksconfig={}, + runtime_hooks=["pyinstaller/runtime_hook_truststore.py"], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="vibe", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name="vibe-dir", +) diff --git a/vibe/__init__.py b/vibe/__init__.py index 8950d2d..72094a1 100644 --- a/vibe/__init__.py +++ b/vibe/__init__.py @@ -3,4 +3,4 @@ from __future__ import annotations from pathlib import Path VIBE_ROOT = Path(__file__).parent -__version__ = "2.13.0" +__version__ = "2.14.0" diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py index 529b00a..773c103 100644 --- a/vibe/acp/acp_agent_loop.py +++ b/vibe/acp/acp_agent_loop.py @@ -133,6 +133,7 @@ from vibe.core.proxy_setup import ( unset_proxy_var, ) from vibe.core.session.saved_sessions import ( + delete_saved_session, update_saved_session_title, update_saved_session_title_at_path, ) @@ -187,6 +188,12 @@ logger = logging.getLogger("vibe") NON_INTERACTIVE_DISABLED_TOOLS = ["ask_user_question", "exit_plan_mode"] +def _merge_non_interactive_disabled_tools(config: VibeConfig) -> None: + for tool in NON_INTERACTIVE_DISABLED_TOOLS: + if tool not in config.disabled_tools: + config.disabled_tools.append(tool) + + class ForkSessionParams(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) @@ -202,6 +209,14 @@ class SessionSetTitleRequest(BaseModel): title: str = Field(min_length=1) +class SessionDeleteRequest(BaseModel): + model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) + + session_id: str = Field( + validation_alias=AliasChoices("session_id", "sessionId"), min_length=1 + ) + + class TelemetrySendNotification(BaseModel): model_config = ConfigDict(extra="ignore") @@ -496,7 +511,8 @@ class VibeAcpAgentLoop(AcpAgent): "browser-auth-delegated": { "attemptId": attempt.process_id, "expiresAt": ( - attempt.expires_at.astimezone(UTC) + attempt.expires_at + .astimezone(UTC) .isoformat() .replace("+00:00", "Z") ), @@ -578,7 +594,8 @@ class VibeAcpAgentLoop(AcpAgent): def _load_config(self) -> VibeConfig: try: - config = VibeConfig.load(disabled_tools=NON_INTERACTIVE_DISABLED_TOOLS) + config = VibeConfig.load() + _merge_non_interactive_disabled_tools(config) config.tool_paths.extend(self._get_acp_tool_overrides()) return config except MissingAPIKeyError as e: @@ -648,6 +665,7 @@ class VibeAcpAgentLoop(AcpAgent): async def new_session( self, cwd: str, + additional_directories: list[str] | None = None, mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None, **kwargs: Any, ) -> NewSessionResponse: @@ -689,9 +707,9 @@ class VibeAcpAgentLoop(AcpAgent): if self.client_capabilities.fs: fs = self.client_capabilities.fs if fs.read_text_file: - overrides.append("read_file") + overrides.append("read") if fs.write_text_file: - overrides.extend(["write_file", "search_replace"]) + overrides.extend(["write_file", "edit"]) return [ VIBE_ROOT / "acp" / "tools" / "builtins" / f"{override}.py" @@ -782,6 +800,13 @@ class VibeAcpAgentLoop(AcpAgent): return None + def _find_live_session_by_requested_session_id( + self, session_id: str + ) -> AcpSessionLoop | None: + return self.sessions.get( + session_id + ) or self._find_acp_session_by_vibe_session_id(session_id) + def _load_session_logging_config(self) -> SessionLoggingConfig: try: return VibeConfig.load().session_logging @@ -904,6 +929,7 @@ class VibeAcpAgentLoop(AcpAgent): self, cwd: str, session_id: str, + additional_directories: list[str] | None = None, mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None, **kwargs: Any, ) -> LoadSessionResponse | None: @@ -969,10 +995,8 @@ class VibeAcpAgentLoop(AcpAgent): return True async def _reload_config(self, session: AcpSessionLoop) -> None: - new_config = VibeConfig.load( - tool_paths=session.agent_loop.config.tool_paths, - disabled_tools=NON_INTERACTIVE_DISABLED_TOOLS, - ) + new_config = VibeConfig.load(tool_paths=session.agent_loop.config.tool_paths) + _merge_non_interactive_disabled_tools(new_config) await session.agent_loop.reload_with_initial_messages(base_config=new_config) async def _apply_model_change(self, session: AcpSessionLoop, model_id: str) -> bool: @@ -1040,7 +1064,11 @@ class VibeAcpAgentLoop(AcpAgent): @override async def list_sessions( - self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any + self, + additional_directories: list[str] | None = None, + cursor: str | None = None, + cwd: str | None = None, + **kwargs: Any, ) -> ListSessionsResponse: try: config = VibeConfig.load() @@ -1190,7 +1218,7 @@ class VibeAcpAgentLoop(AcpAgent): text_prompt = f"{text_prompt}{separator}{block_prompt}" case "resource_link": # NOTE: we currently keep more information than just the URI - # making it more detailed than the output of the read_file tool. + # making it more detailed than the output of the read tool. # This is OK, but might be worth testing how it affect performance. fields = { "uri": block.uri, @@ -1370,6 +1398,7 @@ class VibeAcpAgentLoop(AcpAgent): self, cwd: str, session_id: str, + additional_directories: list[str] | None = None, mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None, **kwargs: Any, ) -> ForkSessionResponse: @@ -1414,6 +1443,7 @@ class VibeAcpAgentLoop(AcpAgent): self, cwd: str, session_id: str, + additional_directories: list[str] | None = None, mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None, **kwargs: Any, ) -> ResumeSessionResponse: @@ -1433,6 +1463,20 @@ class VibeAcpAgentLoop(AcpAgent): session_id=session_id, update=SessionInfoUpdate(**update_kwargs) ) + async def _delete_saved_session(self, session_id: str) -> None: + try: + await delete_saved_session(session_id, self._load_session_logging_config()) + except ValueError as exc: + raise SessionNotFoundError(session_id) from exc + + def _live_session_has_saved_history(self, session: AcpSessionLoop) -> bool: + logger = session.agent_loop.session_logger + return ( + logger.enabled + and logger.session_dir is not None + and logger.metadata_filepath.exists() + ) + async def _persist_live_session_title( self, session: AcpSessionLoop, title: str ) -> dict[str, Any] | None: @@ -1457,6 +1501,40 @@ class VibeAcpAgentLoop(AcpAgent): f"Invalid ACP session title request: {exc}" ) from exc + async def _handle_session_delete(self, params: dict[str, Any]) -> dict[str, Any]: + try: + request = SessionDeleteRequest.model_validate(params) + except ValidationError as exc: + raise InvalidRequestError( + f"Invalid ACP session delete request: {exc}" + ) from exc + + live_session = self._find_live_session_by_requested_session_id( + request.session_id + ) + if live_session is None: + await self._delete_saved_session(request.session_id) + return {} + + saved_session_id = live_session.agent_loop.session_id + has_saved_history = self._live_session_has_saved_history(live_session) + + await self.close_session(live_session.id) + + if not has_saved_history: + return {} + + try: + await delete_saved_session( + saved_session_id, self._load_session_logging_config() + ) + except ValueError as exc: + raise InternalError( + f"Failed to delete saved session {saved_session_id}: {exc}" + ) from exc + + return {} + async def _handle_session_set_title(self, params: dict[str, Any]) -> dict[str, Any]: try: request = SessionSetTitleRequest.model_validate(params) @@ -1465,9 +1543,9 @@ class VibeAcpAgentLoop(AcpAgent): f"Invalid ACP session title request: {exc}" ) from exc - live_session = self.sessions.get( + live_session = self._find_live_session_by_requested_session_id( request.session_id - ) or self._find_acp_session_by_vibe_session_id(request.session_id) + ) if live_session is None: try: metadata = await update_saved_session_title( @@ -1563,6 +1641,9 @@ class VibeAcpAgentLoop(AcpAgent): if method == "session/set_title": return await self._handle_session_set_title(params) + if method == "session/delete": + return await self._handle_session_delete(params) + raise NotImplementedMethodError(method) @override @@ -1681,10 +1762,8 @@ class VibeAcpAgentLoop(AcpAgent): async def _reload_session_config(self, session: AcpSessionLoop) -> None: """Reload config from disk and reinitialize the agent loop.""" - new_config = VibeConfig.load( - tool_paths=session.agent_loop.config.tool_paths, - disabled_tools=NON_INTERACTIVE_DISABLED_TOOLS, - ) + new_config = VibeConfig.load(tool_paths=session.agent_loop.config.tool_paths) + _merge_non_interactive_disabled_tools(new_config) await session.agent_loop.reload_with_initial_messages(base_config=new_config) async def _handle_reload( diff --git a/vibe/acp/tools/builtins/search_replace.py b/vibe/acp/tools/builtins/edit.py similarity index 58% rename from vibe/acp/tools/builtins/search_replace.py rename to vibe/acp/tools/builtins/edit.py index 0145802..6e48898 100644 --- a/vibe/acp/tools/builtins/search_replace.py +++ b/vibe/acp/tools/builtins/edit.py @@ -18,28 +18,22 @@ from vibe.acp.tools.session_update import ( resolve_kind, ) from vibe.core.tools.base import BaseToolState, ToolError -from vibe.core.tools.builtins.search_replace import ( - SearchReplace as CoreSearchReplaceTool, - SearchReplaceArgs, - SearchReplaceResult, -) +from vibe.core.tools.builtins.edit import Edit as CoreEditTool, EditArgs, EditResult from vibe.core.types import ToolCallEvent, ToolResultEvent from vibe.core.utils.io import ReadSafeResult, normalize_newlines -class AcpSearchReplaceState(BaseToolState, AcpToolState): - file_backup_content: str | None = None +class AcpEditState(BaseToolState, AcpToolState): + pass -class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]): - state: AcpSearchReplaceState - prompt_path = ( - VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "search_replace.md" - ) +class Edit(CoreEditTool, BaseAcpTool[AcpEditState]): + state: AcpEditState + prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "edit.md" @classmethod - def _get_tool_state_class(cls) -> type[AcpSearchReplaceState]: - return AcpSearchReplaceState + def _get_tool_state_class(cls) -> type[AcpEditState]: + return AcpEditState async def _read_file(self, file_path: Path) -> ReadSafeResult: client, session_id = self._load_state() @@ -49,58 +43,44 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]): session_id=session_id, path=str(file_path) ) except Exception as e: - raise ToolError(f"Unexpected error reading {file_path}: {e}") from e + raise ToolError(f"Error reading {file_path}: {e}") from e - self.state.file_backup_content = response.content text, newline = normalize_newlines(response.content) return ReadSafeResult(text, "utf-8", newline) - async def _backup_file(self, file_path: Path) -> None: - if self.state.file_backup_content is None: - return - - await self._write_via_client( - file_path.with_suffix(file_path.suffix + ".bak"), - self.state.file_backup_content, - ) - async def _write_file( self, file_path: Path, content: str, encoding: str, newline: str ) -> None: - await self._write_via_client(file_path, content.replace("\n", newline)) - - async def _write_via_client(self, file_path: Path, content: str) -> None: client, session_id = self._load_state() try: await client.write_text_file( - session_id=session_id, path=str(file_path), content=content + session_id=session_id, + path=str(file_path), + content=content.replace("\n", newline), ) except Exception as e: raise ToolError(f"Error writing {file_path}: {e}") from e @classmethod def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None: - if not isinstance(event.args, SearchReplaceArgs): - return fallback_tool_call(event, "search_replace") + if not isinstance(event.args, EditArgs): + return fallback_tool_call(event, "edit") args = event.args - blocks = cls._parse_search_replace_blocks(args.content) - return ToolCallStart( session_update="tool_call", - title=cls.get_call_display(event).summary, + title=cls.format_call_display(args).summary, tool_call_id=event.tool_call_id, kind=resolve_kind(event.tool_name), content=[ FileEditToolCallContent( type="diff", path=args.file_path, - old_text=block.search, - new_text=block.replace, + old_text=args.old_string, + new_text=args.new_string, ) - for block in blocks ], locations=[ToolCallLocation(path=str(Path(args.file_path).resolve()))], raw_input=args.model_dump_json(), @@ -109,13 +89,11 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]): @classmethod def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None: - if failure := failed_tool_result(event, SearchReplaceResult): + if failure := failed_tool_result(event, EditResult): return failure result = event.result - assert isinstance(result, SearchReplaceResult) - - blocks = cls._parse_search_replace_blocks(result.content) + assert isinstance(result, EditResult) return ToolCallProgress( session_update="tool_call_update", @@ -126,10 +104,9 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]): FileEditToolCallContent( type="diff", path=result.file, - old_text=block.search, - new_text=block.replace, + old_text=result.old_string, + new_text=result.new_string, ) - for block in blocks ], locations=[ToolCallLocation(path=str(Path(result.file).resolve()))], raw_output=result.model_dump_json(), diff --git a/vibe/acp/tools/builtins/read_file.py b/vibe/acp/tools/builtins/read.py similarity index 66% rename from vibe/acp/tools/builtins/read_file.py rename to vibe/acp/tools/builtins/read.py index b81785a..15d9683 100644 --- a/vibe/acp/tools/builtins/read_file.py +++ b/vibe/acp/tools/builtins/read.py @@ -21,41 +21,59 @@ from vibe.acp.tools.session_update import ( resolve_kind, ) from vibe.core.tools.base import ToolError -from vibe.core.tools.builtins.read_file import ( - ReadFile as CoreReadFileTool, - ReadFileArgs, - ReadFileResult, - ReadFileState, - _ReadResult, +from vibe.core.tools.builtins.read import ( + Read as CoreReadTool, + ReadArgs, + ReadResult, + ReadState, ) from vibe.core.types import ToolCallEvent, ToolResultEvent -ReadFileResult = ReadFileResult - -class AcpReadFileState(ReadFileState, AcpToolState): +class AcpReadState(ReadState, AcpToolState): pass -class ReadFile( - CoreReadFileTool, - BaseAcpTool[AcpReadFileState], +class Read( + CoreReadTool, + BaseAcpTool[AcpReadState], ToolCallSessionUpdateProtocol, ToolResultSessionUpdateProtocol, ): - state: AcpReadFileState - prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "read_file.md" + state: AcpReadState + prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "read.md" @classmethod - def _get_tool_state_class(cls) -> type[AcpReadFileState]: - return AcpReadFileState + def _get_tool_state_class(cls) -> type[AcpReadState]: + return AcpReadState + + async def _read_file( + self, args: ReadArgs, file_path: Path + ) -> tuple[list[str], int | None, bool]: + client, session_id = self._load_state() + + line = args.offset + limit = args.limit + + try: + response = await client.read_text_file( + session_id=session_id, path=str(file_path), line=line, limit=limit + 1 + ) + except Exception as e: + raise ToolError(f"Error reading {file_path}: {e}") from e + + lines = response.content.splitlines() + total_lines = 0 if not response.content else None + was_truncated = len(lines) > limit + lines = lines[:limit] + return lines, total_lines, was_truncated @classmethod def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None: - if not isinstance(event.args, ReadFileArgs): - return fallback_tool_call(event, "read_file") + if not isinstance(event.args, ReadArgs): + return fallback_tool_call(event, "read") - resolved = str(Path(event.args.path).resolve()) + resolved = str(Path(event.args.file_path).resolve()) return ToolCallStart( session_update="tool_call", @@ -78,19 +96,19 @@ class ReadFile( @classmethod def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None: - if failure := failed_tool_result(event, ReadFileResult): + if failure := failed_tool_result(event, ReadResult): return failure result = event.result - assert isinstance(result, ReadFileResult) - resolved = str(Path(result.path).resolve()) + assert isinstance(result, ReadResult) + resolved = str(Path(result.file_path).resolve()) locations = [ ToolCallLocation( path=resolved, field_meta={ "type": "file_range", - "offset": result.offset, - "limit": result.lines_read, + "offset": result.start_line, + "limit": result.num_lines, }, ) ] @@ -112,26 +130,3 @@ class ReadFile( locations=locations, field_meta={"tool_name": event.tool_name}, ) - - async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult: - client, session_id = self._load_state() - - line = args.offset + 1 if args.offset > 0 else None - limit = args.limit - - try: - response = await client.read_text_file( - session_id=session_id, path=str(file_path), line=line, limit=limit - ) - except Exception as e: - raise ToolError(f"Error reading {file_path}: {e}") from e - - content_lines = response.content.splitlines(keepends=True) - lines_read = len(content_lines) - bytes_read = sum(len(line.encode("utf-8")) for line in content_lines) - - was_truncated = args.limit is not None and lines_read >= args.limit - - return _ReadResult( - lines=content_lines, bytes_read=bytes_read, was_truncated=was_truncated - ) diff --git a/vibe/acp/tools/builtins/todo.py b/vibe/acp/tools/builtins/todo.py index b45831c..e9c5bf2 100644 --- a/vibe/acp/tools/builtins/todo.py +++ b/vibe/acp/tools/builtins/todo.py @@ -1,12 +1,11 @@ from __future__ import annotations -from typing import cast - from acp.helpers import SessionUpdate from acp.schema import AgentPlanUpdate, PlanEntry, PlanEntryPriority, PlanEntryStatus from vibe import VIBE_ROOT from vibe.acp.tools.base import AcpToolState, BaseAcpTool +from vibe.acp.tools.session_update import failed_tool_result from vibe.core.tools.builtins.todo import ( Todo as CoreTodoTool, TodoArgs, @@ -38,7 +37,11 @@ class Todo(CoreTodoTool, BaseAcpTool[AcpTodoState]): @classmethod def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None: - result = cast(TodoResult, event.result) + if failure := failed_tool_result(event, TodoResult): + return failure + + result = event.result + assert isinstance(result, TodoResult) todos = [todo for todo in result.todos if todo.status != TodoStatus.CANCELLED] matched_status: dict[TodoStatus, PlanEntryStatus] = { TodoStatus.PENDING: "pending", diff --git a/vibe/acp/tools/session_update.py b/vibe/acp/tools/session_update.py index ec6d022..3087e96 100644 --- a/vibe/acp/tools/session_update.py +++ b/vibe/acp/tools/session_update.py @@ -40,12 +40,12 @@ def _cancellation_raw_output(event: ToolResultEvent) -> str | None: TOOL_KIND_MAP: dict[str, ToolKind] = { - "read_file": "read", + "read": "read", "grep": "search", "web_search": "search", "web_fetch": "fetch", "write_file": "edit", - "search_replace": "edit", + "edit": "edit", "bash": "execute", "skill": "read", } diff --git a/vibe/cli/clipboard.py b/vibe/cli/clipboard.py index 3051501..75016bc 100644 --- a/vibe/cli/clipboard.py +++ b/vibe/cli/clipboard.py @@ -154,16 +154,27 @@ def copy_text_to_clipboard( if not text: return None - try: - _copy_to_clipboard(text) + if try_copy_text_to_clipboard(text): if show_toast: app.notify(success_message, severity="information", timeout=2, markup=False) return text + + app.notify( + "Failed to copy - clipboard not available", severity="warning", timeout=3 + ) + return None + + +def try_copy_text_to_clipboard(text: str) -> bool: + if not text: + return False + + try: + _copy_to_clipboard(text) except Exception: - app.notify( - "Failed to copy - clipboard not available", severity="warning", timeout=3 - ) - return None + return False + + return True def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None: diff --git a/vibe/cli/entrypoint.py b/vibe/cli/entrypoint.py index 4118b6f..e968a53 100644 --- a/vibe/cli/entrypoint.py +++ b/vibe/cli/entrypoint.py @@ -9,8 +9,14 @@ from rich import print as rprint from vibe import __version__ from vibe.core.config.harness_files import init_harness_files_manager -from vibe.core.trusted_folders import find_trustable_files, trusted_folders_manager +from vibe.core.trusted_folders import ( + find_git_repo_ancestor, + find_repo_trustable_files_for_cwd, + find_trustable_files, + trusted_folders_manager, +) from vibe.setup.trusted_folders.trust_folder_dialog import ( + TrustDecision, TrustDialogQuitException, ask_trust_folder, ) @@ -148,28 +154,49 @@ def check_and_resolve_trusted_folder(cwd: Path) -> None: if cwd.resolve() == Path.home().resolve(): return + if trusted_folders_manager.is_trusted(cwd) is True: + return + if trusted_folders_manager.is_explicitly_untrusted(cwd): + return + + repo_root = find_git_repo_ancestor(cwd) detected_files = find_trustable_files(cwd) - - if not detected_files: + repo_detected_files = find_repo_trustable_files_for_cwd(cwd, repo_root) + if not detected_files and not repo_detected_files: return - is_folder_trusted = trusted_folders_manager.is_trusted(cwd) - - if is_folder_trusted is not None: - return + offer_repo_trust = ( + repo_root is not None + and trusted_folders_manager.is_trusted(repo_root) is not True + and not trusted_folders_manager.is_explicitly_untrusted(repo_root) + ) + repo_explicitly_untrusted = ( + repo_root is not None + and trusted_folders_manager.is_explicitly_untrusted(repo_root) + ) try: - is_folder_trusted = ask_trust_folder(cwd, detected_files) + decision = ask_trust_folder( + cwd, + repo_root, + detected_files, + repo_detected_files=repo_detected_files, + offer_repo_trust=offer_repo_trust, + repo_explicitly_untrusted=repo_explicitly_untrusted, + ) except (KeyboardInterrupt, EOFError, TrustDialogQuitException): sys.exit(0) except Exception as e: rprint(f"[yellow]Error showing trust dialog: {e}[/]") return - if is_folder_trusted is True: - trusted_folders_manager.add_trusted(cwd) - elif is_folder_trusted is False: - trusted_folders_manager.add_untrusted(cwd) + match decision: + case TrustDecision.TRUST_REPO if repo_root is not None: + trusted_folders_manager.add_trusted(repo_root) + case TrustDecision.TRUST_CWD: + trusted_folders_manager.add_trusted(cwd) + case TrustDecision.DECLINE: + trusted_folders_manager.add_untrusted(cwd) def main() -> None: diff --git a/vibe/cli/plan_offer/decide_plan_offer.py b/vibe/cli/plan_offer/decide_plan_offer.py index fc66fed..561055e 100644 --- a/vibe/cli/plan_offer/decide_plan_offer.py +++ b/vibe/cli/plan_offer/decide_plan_offer.py @@ -117,7 +117,7 @@ def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911 if payload.is_chat_pro_plan(): return "[Subscription] Pro" if payload.is_free_api_plan(): - return "[API] Experiment plan" + return "Free" if payload.is_paid_api_plan(): return "[API] Scale plan" if payload.is_free_mistral_code_plan(): diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index 1c826b2..a792a3b 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -136,10 +136,15 @@ from vibe.core.agents import AgentProfile from vibe.core.audio_player.audio_player import AudioPlayer from vibe.core.audio_recorder import AudioRecorder from vibe.core.autocompletion.path_prompt import ( + PathPromptPayload, + PathResource, build_path_prompt_payload, build_title_segments, ) -from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt +from vibe.core.autocompletion.path_prompt_adapter import ( + extract_image_resources, + render_path_prompt_from_payload, +) from vibe.core.config import DEFAULT_THEME, VibeConfig from vibe.core.data_retention import DATA_RETENTION_MESSAGE from vibe.core.hooks.models import HookStartEvent @@ -147,6 +152,7 @@ from vibe.core.log_reader import LogReader from vibe.core.logger import logger from vibe.core.paths import HISTORY_FILE from vibe.core.rewind import RewindError +from vibe.core.session.image_snapshot import ImageSnapshotError, snapshot_image from vibe.core.session.resume_sessions import ( ResumeSessionInfo, list_local_resume_sessions, @@ -172,15 +178,18 @@ from vibe.core.tools.builtins.ask_user_question import ( Choice, Question, ) -from vibe.core.tools.connectors import ConnectorRegistry +from vibe.core.tools.connectors import compute_connector_counts from vibe.core.tools.mcp_settings import persist_mcp_toggle from vibe.core.tools.permissions import RequiredPermission from vibe.core.transcribe import make_transcribe_client from vibe.core.types import ( + MAX_IMAGE_BYTES, + MAX_IMAGES_PER_MESSAGE, AgentStats, ApprovalResponse, BaseEvent, ContextTooLongError, + ImageAttachment, LLMMessage, RateLimitError, Role, @@ -199,20 +208,6 @@ def _is_vscode_family_terminal() -> bool: return detect_terminal() in _VSCODE_FAMILY_TERMINALS -def _compute_connector_counts( - config: VibeConfig, connector_registry: ConnectorRegistry | None -) -> tuple[int, int]: - total = connector_registry.connector_count if connector_registry else 0 - if total == 0: - return (0, 0) - disabled_names = {c.name for c in config.connectors if c.disabled} - known_names = set( - connector_registry.get_connector_names() if connector_registry else [] - ) - enabled = total - len(disabled_names & known_names) - return (enabled, total) - - class BottomApp(StrEnum): """Bottom panel app types. @@ -335,6 +330,12 @@ class StartupOptions: is_resuming_session: bool = False +@dataclass(frozen=True, slots=True) +class _ImageAttachmentRejection: + message: str + no_vision: bool = False + + class VibeApp(App): # noqa: PLR0904 ENABLE_COMMAND_PALETTE = False CSS_PATH = "app.tcss" @@ -487,13 +488,13 @@ class VibeApp(App): # noqa: PLR0904 def compose(self) -> ComposeResult: with ChatScroll(id="chat"): - connectors_enabled, connectors_total = _compute_connector_counts( + connectors_connected, connectors_total = compute_connector_counts( self.config, self.agent_loop.connector_registry ) self._banner = Banner( config=self.config, skill_manager=self.agent_loop.skill_manager, - connectors_enabled=connectors_enabled, + connectors_connected=connectors_connected, connectors_total=connectors_total, ) yield self._banner @@ -569,7 +570,7 @@ class VibeApp(App): # noqa: PLR0904 self.agent_loop.start_initialize_experiments() self.call_after_refresh(self._refresh_banner) - self._show_hook_config_issues_once() + self._show_config_issues() self.run_worker(self._watch_init_completion(), exclusive=False) @@ -581,8 +582,11 @@ class VibeApp(App): # noqa: PLR0904 gc.collect() gc.freeze() - def _show_hook_config_issues_once(self) -> None: - for issue in self.agent_loop.hook_config_issues: + def _show_config_issues(self) -> None: + for issue in ( + *self.agent_loop.hook_config_issues, + *self.agent_loop.skill_manager.config_issues, + ): self.notify( f"{issue.file}\n{issue.message}", severity="warning", @@ -759,6 +763,83 @@ class VibeApp(App): # noqa: PLR0904 await self._loading_widget.remove() self._loading_widget = None + async def _resolve_turn_images( + self, payload: PathPromptPayload, prebuilt: list[ImageAttachment] | None + ) -> list[ImageAttachment] | None: + if prebuilt is not None: + return prebuilt + return await self._prepare_images_or_abort(payload) + + async def _prepare_images_or_abort( + self, payload: PathPromptPayload + ) -> list[ImageAttachment] | None: + result = await self._build_image_attachments(payload) + if isinstance(result, _ImageAttachmentRejection): + await self._remove_loading_widget() + if result.no_vision: + await self._mount_and_scroll( + ErrorMessage(result.message, show_border=False) + ) + else: + await self._mount_and_scroll( + ErrorMessage(result.message, collapsed=self._tools_collapsed) + ) + return None + return result + + async def _build_image_attachments( + self, payload: PathPromptPayload + ) -> list[ImageAttachment] | _ImageAttachmentRejection: + image_resources = extract_image_resources(payload) + if not image_resources: + return [] + + if len(image_resources) > MAX_IMAGES_PER_MESSAGE: + return _ImageAttachmentRejection( + f"Too many image attachments (got {len(image_resources)}, " + f"max {MAX_IMAGES_PER_MESSAGE})." + ) + + try: + active_model = self.agent_loop.config.get_active_model() + except ValueError: + active_model = None + if active_model is not None and not active_model.supports_images: + return _ImageAttachmentRejection( + f"Model `{active_model.alias}` does not support images. " + f"Switch with /model, remove the attachment, or ask me to enable the support for this model.", + no_vision=True, + ) + + attachments: list[ImageAttachment] = [] + session_dir = self.agent_loop.session_logger.session_dir + for resource in image_resources: + result = self._snapshot_single_image(resource, session_dir) + if isinstance(result, str): + return _ImageAttachmentRejection(result) + attachments.append(result) + return attachments + + def _snapshot_single_image( + self, resource: PathResource, session_dir: Path | None + ) -> ImageAttachment | str: + try: + size = resource.path.stat().st_size + except OSError as e: + return f"Cannot read image {resource.alias}: {e}" + if size > MAX_IMAGE_BYTES: + return ( + f"Image `{resource.alias}` is " + f"{size / (1024 * 1024):.1f} MB; max is " + f"{MAX_IMAGE_BYTES // (1024 * 1024)} MB." + ) + try: + return snapshot_image( + resource.path, alias=resource.alias, session_dir=session_dir + ) + except ImageSnapshotError as e: + return f"Failed to attach image {resource.alias}: {e}" + async def on_config_app_open_model_picker( self, _message: ConfigApp.OpenModelPicker ) -> None: @@ -1203,10 +1284,20 @@ class VibeApp(App): # noqa: PLR0904 await self._handle_remote_user_message(message) return + prompt_payload = build_path_prompt_payload(message, base_dir=Path.cwd()) + images = await self._prepare_images_or_abort(prompt_payload) + if images is None: + input_widget = self.query_one(ChatInputContainer) + if not input_widget.value: + input_widget.value = message + return + # message_index is where the user message will land in agent_loop.messages # (checkpoint is created in agent_loop.act()) message_index = len(self.agent_loop.messages) - user_message = UserMessage(message, message_index=message_index) + user_message = UserMessage( + message, message_index=message_index, images=images or None + ) await self._mount_and_scroll(user_message) if self._feedback_bar_manager.should_show(self.agent_loop): @@ -1217,7 +1308,12 @@ class VibeApp(App): # noqa: PLR0904 await self._remote_manager.stop_stream() await self._remove_loading_widget() self._agent_task = asyncio.create_task( - self._handle_agent_loop_turn(message, title_source=title_source) + self._handle_agent_loop_turn( + message, + title_source=title_source, + prebuilt_images=images, + prebuilt_payload=prompt_payload, + ) ) async def _handle_remote_user_message(self, message: str) -> None: @@ -1419,7 +1515,12 @@ class VibeApp(App): # noqa: PLR0904 ) async def _handle_agent_loop_turn( - self, prompt: str, *, title_source: str | None = None + self, + prompt: str, + *, + title_source: str | None = None, + prebuilt_images: list[ImageAttachment] | None = None, + prebuilt_payload: PathPromptPayload | None = None, ) -> None: self._agent_running = True @@ -1429,7 +1530,9 @@ class VibeApp(App): # noqa: PLR0904 await self._handle_agent_loop_init() await self._ensure_loading_widget() message_id = str(uuid4()) - prompt_payload = build_path_prompt_payload(prompt, base_dir=Path.cwd()) + prompt_payload = prebuilt_payload or build_path_prompt_payload( + prompt, base_dir=Path.cwd() + ) if prompt_payload.all_resources: context_types: dict[str, int] = {} for r in prompt_payload.all_resources: @@ -1446,7 +1549,12 @@ class VibeApp(App): # noqa: PLR0904 file_extensions=file_ext_counts or None, message_id=message_id, ) - rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd()) + images = await self._resolve_turn_images(prompt_payload, prebuilt_images) + if images is None: + return + rendered_prompt = render_path_prompt_from_payload( + prompt_payload, skip_images=True + ) auto_title: str | None = None if self.agent_loop.session_logger.needs_initial_auto_title(): auto_title = ( @@ -1461,7 +1569,10 @@ class VibeApp(App): # noqa: PLR0904 self._narrator_manager.on_turn_start(rendered_prompt) async with aclosing( self.agent_loop.act( - rendered_prompt, client_message_id=message_id, auto_title=auto_title + rendered_prompt, + client_message_id=message_id, + auto_title=auto_title, + images=images or None, ) ) as events: await self._handle_agent_loop_events(events) @@ -1752,7 +1863,7 @@ class VibeApp(App): # noqa: PLR0904 tool_manager=self.agent_loop.tool_manager, initial_server=name, connector_registry=connector_registry, - get_connector_configs=lambda: self.agent_loop.config.connectors, + get_vibe_config=lambda: self.agent_loop.config, refresh_callback=self._refresh_mcp_browser, ) ) @@ -2059,21 +2170,37 @@ class VibeApp(App): # noqa: PLR0904 self._narrator_manager.sync() if self._banner: - ce, ct = _compute_connector_counts( + cc, ct = compute_connector_counts( base_config, self.agent_loop.connector_registry ) self._banner.set_state( base_config, self.agent_loop.skill_manager, - connectors_enabled=ce, + connectors_connected=cc, connectors_total=ct, plan_description=plan_title(self._plan_info), ) + self._show_config_issues() await self._mount_and_scroll( UserCommandMessage( "Configuration reloaded (includes agent instructions and skills)." ) ) + stripped_count = ( + self.agent_loop.count_history_images_unsupported_by_active_model() + ) + if stripped_count > 0: + try: + model_alias = self.agent_loop.config.get_active_model().alias + except ValueError: + model_alias = "the active model" + noun = "image" if stripped_count == 1 else "images" + await self._mount_and_scroll( + WarningMessage( + f"{stripped_count} {noun} from earlier turns will be omitted " + f"when sending to {model_alias} (no vision support)." + ) + ) except Exception as e: await self._mount_and_scroll( ErrorMessage( @@ -2849,13 +2976,13 @@ class VibeApp(App): # noqa: PLR0904 def _refresh_banner(self) -> None: if self._banner: - ce, ct = _compute_connector_counts( + cc, ct = compute_connector_counts( self.config, self.agent_loop.connector_registry ) self._banner.set_state( self.config, self.agent_loop.skill_manager, - connectors_enabled=ce, + connectors_connected=cc, connectors_total=ct, plan_description=plan_title(self._plan_info), ) diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss index c3f92e1..955fbc5 100644 --- a/vibe/cli/textual_ui/app.tcss +++ b/vibe/cli/textual_ui/app.tcss @@ -292,6 +292,22 @@ Markdown { height: auto; } +.user-message-row { + width: 100%; + height: auto; +} + +.user-message-attachments { + width: 100%; + height: auto; + padding-left: 2; + color: $text-muted; + + &:ansi { + text-style: dim; + } +} + .user-message-prompt { width: auto; height: auto; @@ -934,7 +950,7 @@ StatusMessage { #approval-app { width: 100%; - height: auto; + /* height set by ApprovalApp._recompute_height; max-height below caps it */ max-height: 70vh; background: transparent; border: solid $foreground-muted; @@ -945,18 +961,16 @@ StatusMessage { #approval-options { width: 100%; height: auto; - dock: bottom; } #approval-content { width: 100%; - height: auto; + height: 1fr; } .approval-tool-info-scroll { width: 100%; - height: auto; - max-height: 50vh; + height: 1fr; } .approval-title { diff --git a/vibe/cli/textual_ui/widgets/approval_app.py b/vibe/cli/textual_ui/widgets/approval_app.py index 6c7a244..770ae14 100644 --- a/vibe/cli/textual_ui/widgets/approval_app.py +++ b/vibe/cli/textual_ui/widgets/approval_app.py @@ -9,6 +9,7 @@ from textual.app import ComposeResult from textual.binding import Binding, BindingType from textual.containers import Container, Vertical, VerticalScroll from textual.message import Message +from textual.widget import Widget from textual.widgets import Static from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic @@ -87,13 +88,24 @@ class ApprovalApp(Container): self.required_permissions = required_permissions or [] self.selected_option = 0 self.content_container: Vertical | None = None - self.title_widget: Static | None = None + self.title_widget = NoMarkupStatic( + self._build_title(), classes="approval-title" + ) self.tool_info_container: Vertical | None = None self.option_widgets: list[Static] = [] self.help_widget: Static | None = None self._mount_time: float = 0.0 def compose(self) -> ComposeResult: + with Vertical(id="approval-content"): + yield self.title_widget + + with VerticalScroll(classes="approval-tool-info-scroll"): + self.tool_info_container = Vertical( + classes="approval-tool-info-container" + ) + yield self.tool_info_container + with Vertical(id="approval-options"): yield NoMarkupStatic("") for _ in range(self.NUM_OPTIONS): @@ -105,17 +117,6 @@ class ApprovalApp(Container): ) yield self.help_widget - with Vertical(id="approval-content"): - title = self._build_title() - self.title_widget = NoMarkupStatic(title, classes="approval-title") - yield self.title_widget - - with VerticalScroll(classes="approval-tool-info-scroll"): - self.tool_info_container = Vertical( - classes="approval-tool-info-container" - ) - yield self.tool_info_container - def _build_title(self) -> str: if self.required_permissions: labels = ", ".join(rp.label for rp in self.required_permissions) @@ -127,6 +128,33 @@ class ApprovalApp(Container): await self._update_tool_info() self._update_options() self.focus() + self._recompute_height() + self.screen.screen_layout_refresh_signal.subscribe( + self, lambda _screen: self._recompute_height() + ) + + def _recompute_height(self) -> None: + """Manual sizing: the scroll uses `1fr`, so `height: auto` cannot shrink to fit.""" + options = self.query_one("#approval-options", Vertical) + scroll = self.query_one(".approval-tool-info-scroll", VerticalScroll) + + natural_height = ( + options.outer_size.height + + self.title_widget.outer_size.height + + scroll.virtual_size.height + + self.gutter.height + ) + + # Cap the natural height if greater than max_height + if max_height := self.styles.max_height: + viewport = self.app.size + parent_size = ( + self.parent.size if isinstance(self.parent, Widget) else viewport + ) + resolved_max_height = int(max_height.resolve(parent_size, viewport)) + natural_height = min(natural_height, resolved_max_height) + + self.styles.height = natural_height def is_within_grace_period(self) -> bool: return (time.monotonic() - self._mount_time) < _INPUT_GRACE_PERIOD_S diff --git a/vibe/cli/textual_ui/widgets/banner/banner.py b/vibe/cli/textual_ui/widgets/banner/banner.py index dfadae3..98fed88 100644 --- a/vibe/cli/textual_ui/widgets/banner/banner.py +++ b/vibe/cli/textual_ui/widgets/banner/banner.py @@ -25,7 +25,7 @@ class BannerState: models_count: int = 0 mcp_servers_enabled: int = 0 mcp_servers_total: int = 0 - connectors_enabled: int = 0 + connectors_connected: int = 0 connectors_total: int = 0 skills_count: int = 0 plan_description: str | None = None @@ -38,7 +38,7 @@ class Banner(Static): self, config: VibeConfig, skill_manager: SkillManager, - connectors_enabled: int = 0, + connectors_connected: int = 0, connectors_total: int = 0, **kwargs: Any, ) -> None: @@ -47,7 +47,7 @@ class Banner(Static): self._initial_state = self._build_state( config=config, skill_manager=skill_manager, - connectors_enabled=connectors_enabled, + connectors_connected=connectors_connected, connectors_total=connectors_total, plan_description=None, ) @@ -91,14 +91,14 @@ class Banner(Static): self, config: VibeConfig, skill_manager: SkillManager, - connectors_enabled: int = 0, + connectors_connected: int = 0, connectors_total: int = 0, plan_description: str | None = None, ) -> None: self.state = self._build_state( config, skill_manager, - connectors_enabled, + connectors_connected, connectors_total, plan_description, ) @@ -107,7 +107,7 @@ class Banner(Static): def _build_state( config: VibeConfig, skill_manager: SkillManager, - connectors_enabled: int = 0, + connectors_connected: int = 0, connectors_total: int = 0, plan_description: str | None = None, ) -> BannerState: @@ -120,7 +120,7 @@ class Banner(Static): models_count=len(config.models), mcp_servers_enabled=len(enabled_servers), mcp_servers_total=len(all_servers), - connectors_enabled=connectors_enabled, + connectors_connected=connectors_connected, connectors_total=connectors_total, skills_count=skill_manager.custom_skills_count, plan_description=plan_description, @@ -130,13 +130,13 @@ class Banner(Static): parts = [_pluralize(self.state.models_count, "model")] # Format as x/y for MCP servers and connectors (only when enabled != total) if self.state.connectors_total > 0: - if self.state.connectors_enabled != self.state.connectors_total: + if self.state.connectors_connected != self.state.connectors_total: connector_str = ( - f"{self.state.connectors_enabled}/{self.state.connectors_total} connector" + f"{self.state.connectors_connected}/{self.state.connectors_total} connector" + ("s" if self.state.connectors_total != 1 else "") ) else: - connector_str = _pluralize(self.state.connectors_enabled, "connector") + connector_str = _pluralize(self.state.connectors_connected, "connector") parts.append(connector_str) # Always show MCP servers count (even if 0/0) if self.state.mcp_servers_enabled != self.state.mcp_servers_total: diff --git a/vibe/cli/textual_ui/widgets/chat_input/paste_path.py b/vibe/cli/textual_ui/widgets/chat_input/paste_path.py new file mode 100644 index 0000000..3d1a000 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/chat_input/paste_path.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from pathlib import Path + +from vibe.core.types import IMAGE_EXTENSIONS + +_QUOTES: frozenset[str] = frozenset({"'", '"'}) +_PATH_ROOTS: frozenset[str] = frozenset({"/", "~"}) +_TOKEN_BOUNDARY_CHARS: frozenset[str] = frozenset("(<[") +_MIN_QUOTED_LEN = 2 + + +def maybe_prepend_at_for_image_path(pasted: str) -> str: + text = pasted.strip() + if not text or "\n" in text or "\r" in text: + return pasted + candidate = _unescape_spaces(_strip_matched_quotes(text)) + if not candidate or candidate[0] not in _PATH_ROOTS: + return pasted + if not _is_image_file(candidate): + return pasted + return f"@{_quote_if_needed(candidate)}" + + +def rewrite_bare_image_paths_in_text(text: str) -> str: + """Scan `text` for bare absolute image paths (raw, backslash-escaped, or + quoted) and prepend `@` to each. Idempotent: tokens already preceded by + `@` are not touched. Used as a text-changed hook to recover the UX of + drag-and-drop in terminals that do not emit bracketed-paste sequences. + """ + # Per-keystroke fast path: a bare path token must start with `/`, `~`, or + # a quote, so skip the per-token stat() walk if none are present. + if not any(ch in text for ch in "/~'\""): + return text + out: list[str] = [] + pos = 0 + while pos < len(text): + if _at_token_boundary(text, pos): + token, end = _extract_path_token(text, pos) + if token is not None and _is_image_file(token): + out.append(f"@{_quote_if_needed(token)}") + pos = end + continue + out.append(text[pos]) + pos += 1 + return "".join(out) + + +def _is_image_file(candidate: str) -> bool: + try: + resolved = Path(candidate).expanduser() + except RuntimeError: + # `~unknownuser/...` raises when the user cannot be resolved. + return False + return ( + resolved.is_absolute() + and resolved.suffix.lower() in IMAGE_EXTENSIONS + and resolved.is_file() + ) + + +def _quote_if_needed(path: str) -> str: + return f"'{path}'" if " " in path else path + + +def _unescape_spaces(text: str) -> str: + return text.replace("\\ ", " ") + + +def _strip_matched_quotes(text: str) -> str: + if len(text) >= _MIN_QUOTED_LEN and text[0] in _QUOTES and text[-1] == text[0]: + return text[1:-1] + return text + + +def _at_token_boundary(text: str, pos: int) -> bool: + if pos == 0: + return True + prev = text[pos - 1] + if prev == "@": + return False + return prev.isspace() or prev in _TOKEN_BOUNDARY_CHARS + + +def _extract_path_token(text: str, pos: int) -> tuple[str | None, int]: + head = text[pos] + if head in _QUOTES: + return _extract_quoted(text, pos, quote=head) + if head in _PATH_ROOTS: + return _extract_bare(text, pos) + return None, pos + + +def _extract_quoted(text: str, start: int, *, quote: str) -> tuple[str | None, int]: + end = text.find(quote, start + 1) + if end == -1: + return None, start + return text[start + 1 : end], end + 1 + + +def _extract_bare(text: str, start: int) -> tuple[str | None, int]: + out: list[str] = [] + end = start + n = len(text) + while end < n: + ch = text[end] + if ch == "\\" and end + 1 < n and text[end + 1] == " ": + out.append(" ") + end += 2 + continue + if ch.isspace(): + break + out.append(ch) + end += 1 + if end == start: + return None, start + return "".join(out), end diff --git a/vibe/cli/textual_ui/widgets/chat_input/text_area.py b/vibe/cli/textual_ui/widgets/chat_input/text_area.py index a6f0b22..04b5120 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/text_area.py +++ b/vibe/cli/textual_ui/widgets/chat_input/text_area.py @@ -14,6 +14,10 @@ from vibe.cli.textual_ui.external_editor import ExternalEditor from vibe.cli.textual_ui.widgets.chat_input.completion_manager import ( MultiCompletionManager, ) +from vibe.cli.textual_ui.widgets.chat_input.paste_path import ( + maybe_prepend_at_for_image_path, + rewrite_bare_image_paths_in_text, +) from vibe.cli.textual_ui.widgets.vscode_compat import patch_vscode_space from vibe.cli.voice_manager.voice_manager_port import ( RecordingStartError, @@ -33,6 +37,8 @@ class ChatTextArea(TextArea): show=False, priority=True, ), + Binding("shift+backspace", "delete_left", "Delete character left", show=False), + Binding("shift+delete", "delete_right", "Delete character right", show=False), Binding("ctrl+g", "open_external_editor", "External Editor", show=False), ] @@ -91,6 +97,17 @@ class ChatTextArea(TextArea): def on_click(self, event: events.Click) -> None: self._mark_cursor_moved_if_needed() + async def _on_paste(self, event: events.Paste) -> None: + # Best-effort: terminals that emit bracketed paste sequences will + # land here, and we can rewrite event.text directly. event.stop() + # prevents App.on_event from auto-forwarding the Paste back to the + # focused widget (which would otherwise dispatch this handler a + # second time and double-insert). TextArea._on_paste in the same + # MRO still runs inside this dispatch cycle and performs the + # single insertion using the mutated text. + event.text = maybe_prepend_at_for_image_path(event.text) + event.stop() + def action_insert_newline(self) -> None: self.insert("\n") @@ -106,6 +123,19 @@ class ChatTextArea(TextArea): self.insert(result) def on_text_area_changed(self, event: TextArea.Changed) -> None: + # Fallback for terminals that deliver drag-n-drop as a bulk text + # edit rather than a Paste event (so _on_paste never fires). + # Scan the full text for any bare absolute image path token and + # rewrite it to @. Idempotent: tokens already preceded by + # `@` are skipped, so this is safe to run on every change. + current = self.text + rewritten = rewrite_bare_image_paths_in_text(current) + if rewritten != current: + self.text = rewritten + last_line = rewritten.rsplit("\n", 1)[-1] + self.move_cursor((rewritten.count("\n"), len(last_line))) + return + if not self._navigating_history and self.text != self._last_text: self._original_text = "" self._cursor_pos_after_load = None @@ -268,7 +298,10 @@ class ChatTextArea(TextArea): event.stop() return - if event.key == "backspace" and self._should_reset_mode_on_backspace(): + if ( + event.key in {"backspace", "shift+backspace"} + and self._should_reset_mode_on_backspace() + ): self._set_mode(self.DEFAULT_MODE) event.prevent_default() event.stop() diff --git a/vibe/cli/textual_ui/widgets/mcp_app.py b/vibe/cli/textual_ui/widgets/mcp_app.py index a84273e..92c5817 100644 --- a/vibe/cli/textual_ui/widgets/mcp_app.py +++ b/vibe/cli/textual_ui/widgets/mcp_app.py @@ -15,7 +15,7 @@ from textual.widgets.option_list import Option, OptionDoesNotExist from textual.worker import Worker from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic -from vibe.core.config import ConnectorConfig +from vibe.core.config import ConnectorConfig, VibeConfig from vibe.core.tools.connectors import ConnectorAuthAction, ConnectorRegistry from vibe.core.tools.mcp.tools import MCPTool from vibe.core.tools.mcp_settings import updated_tool_list @@ -123,19 +123,19 @@ class MCPApp(Container): tool_manager: ToolManager, initial_server: str = "", connector_registry: ConnectorRegistry | None = None, - get_connector_configs: Callable[[], list[ConnectorConfig]] | None = None, + get_vibe_config: Callable[[], VibeConfig] | None = None, refresh_callback: Callable[[], Awaitable[str]] | None = None, ) -> None: super().__init__(id="mcp-app") self._mcp_servers = mcp_servers self._connector_registry = connector_registry - self._get_connector_configs = get_connector_configs or (lambda: []) + self._get_vibe_config = get_vibe_config connector_names = ( connector_registry.get_connector_names() if connector_registry else [] ) self._connector_names = connector_names self._sorted_connector_names = _sort_connector_names_for_menu( - connector_names, connector_registry + connector_names, connector_registry, self._current_disabled_names() ) self._tool_manager = tool_manager self._index = collect_mcp_tool_index(mcp_servers, tool_manager, connector_names) @@ -163,7 +163,9 @@ class MCPApp(Container): if self._connector_registry: self._connector_names = self._connector_registry.get_connector_names() self._sorted_connector_names = _sort_connector_names_for_menu( - self._connector_names, self._connector_registry + self._connector_names, + self._connector_registry, + self._current_disabled_names(), ) self._rebuild_preserving_scroll() @@ -259,9 +261,22 @@ class MCPApp(Container): text = f"{self._status_message} {text}" self.query_one("#mcp-help", NoMarkupStatic).update(text) + def _connector_configs(self) -> list[ConnectorConfig]: + return self._get_vibe_config().connectors if self._get_vibe_config else [] + def _find_connector_config(self, name: str) -> ConnectorConfig | None: - """Look up a connector config by name from the live VibeConfig source.""" - return next((c for c in self._get_connector_configs() if c.name == name), None) + return next((c for c in self._connector_configs() if c.name == name), None) + + def _current_disabled_names(self) -> set[str]: + config = self._get_vibe_config() if self._get_vibe_config else None + if config is None: + return set(self._connector_names) + by_name = config.connectors_by_name() + return { + n + for n in self._connector_names + if (cfg := by_name.get(n)) is None or cfg.disabled + } def action_disable(self) -> None: self._set_highlighted_disabled(disabled=True) @@ -290,7 +305,7 @@ class MCPApp(Container): cfg = self._find_connector_config(name) if cfg is None: cfg = ConnectorConfig(name=name, disabled=disabled) - self._get_connector_configs().append(cfg) + self._connector_configs().append(cfg) else: cfg.disabled = disabled @@ -342,7 +357,7 @@ class MCPApp(Container): cfg = ConnectorConfig( name=server_name, disabled_tools=[remote_name] if disabled else [] ) - self._get_connector_configs().append(cfg) + self._connector_configs().append(cfg) else: cfg.disabled_tools = updated_tool_list( cfg.disabled_tools, remote_name, disabled @@ -366,6 +381,11 @@ class MCPApp(Container): self._index = collect_mcp_tool_index( self._mcp_servers, self._tool_manager, self._connector_names ) + self._sorted_connector_names = _sort_connector_names_for_menu( + self._connector_names, + self._connector_registry, + self._current_disabled_names(), + ) self._refresh_view(self._viewing_server, kind=self._viewing_kind) if saved_option_id is not None: @@ -612,12 +632,15 @@ def _tool_count_text(enabled: int, total: int | None = None) -> str: def _sort_connector_names_for_menu( - connector_names: Sequence[str], connector_registry: ConnectorRegistry | None + connector_names: Sequence[str], + connector_registry: ConnectorRegistry | None, + disabled_names: set[str], ) -> list[str]: - return sorted( - connector_names, - key=lambda name: ( - not connector_registry.is_connected(name) if connector_registry else True, - name.lower(), - ), - ) + def key(name: str) -> tuple[bool, bool, str]: + is_disabled = name in disabled_names + is_connected = ( + connector_registry.is_connected(name) if connector_registry else False + ) + return (is_disabled, not is_connected, name.lower()) + + return sorted(connector_names, key=key) diff --git a/vibe/cli/textual_ui/widgets/messages.py b/vibe/cli/textual_ui/widgets/messages.py index ecd7ba4..21312d8 100644 --- a/vibe/cli/textual_ui/widgets/messages.py +++ b/vibe/cli/textual_ui/widgets/messages.py @@ -4,8 +4,11 @@ import asyncio from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, cast +from rich.markup import escape + from vibe.core.hooks.models import HookMessageSeverity from vibe.core.logger import logger +from vibe.core.types import ImageAttachment from vibe.core.utils.io import read_safe_async if TYPE_CHECKING: @@ -62,12 +65,17 @@ class UserMessage(Static): SHOW_SEPARATOR: ClassVar[bool] = True def __init__( - self, content: str, pending: bool = False, message_index: int | None = None + self, + content: str, + pending: bool = False, + message_index: int | None = None, + images: list[ImageAttachment] | None = None, ) -> None: super().__init__() self.add_class("user-message") self._content = content self._pending = pending + self._images = images or [] self.message_index: int | None = message_index def get_content(self) -> str: @@ -80,10 +88,28 @@ class UserMessage(Static): f"{self.PROMPT_CHAR} ", classes="user-message-prompt" ) yield NoMarkupStatic(self._content, classes="user-message-content") + if self._images: + yield Static( + self._format_attachments_footer(self._images), + classes="user-message-attachments", + markup=True, + ) if self.SHOW_SEPARATOR: yield ExpandingSeparator(classes="user-message-separator") - if self._pending: - self.add_class("pending") + if self._pending: + self.add_class("pending") + + @staticmethod + def _format_attachments_footer(images: list[ImageAttachment]) -> str: + label = "attached image" if len(images) == 1 else "attached images" + # Use Textual [link="..."] markup with the URL quoted: Textual's + # markup parser stops at `:` inside an unquoted tag value, so a raw + # `file://...` URL would raise MarkupError. Textual auto-wires the + # click to webbrowser.open(url), opening the OS default viewer. + links = ", ".join( + f'[link="{att.path.as_uri()}"]{escape(att.alias)}[/link]' for att in images + ) + return f"└ {label}: {links}" async def set_pending(self, pending: bool) -> None: if pending == self._pending: @@ -429,19 +455,22 @@ class BashOutputMessage(SpinnerMixin, Static): class ErrorMessage(Static): - def __init__(self, error: str, collapsed: bool = False) -> None: + def __init__( + self, error: str, collapsed: bool = False, show_border: bool = True + ) -> None: super().__init__() self.add_class("error-message") self._error = error self.collapsed = collapsed + self._show_border = show_border self._content_widget: Static | None = None def compose(self) -> ComposeResult: with Horizontal(classes="error-container"): - yield ExpandingBorder(classes="error-border") - self._content_widget = NoMarkupStatic( - f"Error: {self._error}", classes="error-content" - ) + if self._show_border: + yield ExpandingBorder(classes="error-border") + text = f"Error: {self._error}" if self._show_border else self._error + self._content_widget = NoMarkupStatic(text, classes="error-content") yield self._content_widget def set_collapsed(self, collapsed: bool) -> None: diff --git a/vibe/cli/textual_ui/widgets/tool_widgets.py b/vibe/cli/textual_ui/widgets/tool_widgets.py index 021b72f..8e092d2 100644 --- a/vibe/cli/textual_ui/widgets/tool_widgets.py +++ b/vibe/cli/textual_ui/widgets/tool_widgets.py @@ -2,6 +2,7 @@ from __future__ import annotations import difflib from pathlib import Path +import re from pydantic import BaseModel from textual.app import ComposeResult @@ -11,16 +12,19 @@ from textual.widgets import Markdown, Static from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.tools.builtins.ask_user_question import AskUserQuestionResult from vibe.core.tools.builtins.bash import BashArgs, BashResult +from vibe.core.tools.builtins.edit import EditArgs, EditResult from vibe.core.tools.builtins.grep import GrepArgs, GrepResult -from vibe.core.tools.builtins.read_file import ReadFileArgs, ReadFileResult -from vibe.core.tools.builtins.search_replace import ( - SEARCH_REPLACE_BLOCK_RE, - SearchReplaceArgs, - SearchReplaceResult, -) +from vibe.core.tools.builtins.read import ReadArgs, ReadResult from vibe.core.tools.builtins.todo import TodoArgs, TodoResult from vibe.core.tools.builtins.write_file import WriteFileArgs, WriteFileResult +_LINE_NUMBER_PREFIX = re.compile(r"^ *\d+→") + + +def _strip_line_numbers(content: str) -> str: + """Remove the model-facing `` 12→`` line-number prefixes for CLI display.""" + return "\n".join(_LINE_NUMBER_PREFIX.sub("", line) for line in content.split("\n")) + def _truncate_lines(content: str, max_lines: int) -> tuple[str, str | None]: """Truncate content to max_lines, returning (content, truncation_info).""" @@ -31,24 +35,6 @@ def _truncate_lines(content: str, max_lines: int) -> tuple[str, str | None]: return "\n".join(lines[:max_lines]), f"… ({remaining} more lines)" -def parse_search_replace_to_diff(content: str) -> list[str]: - """Parse SEARCH/REPLACE blocks and generate unified diff lines.""" - all_diff_lines: list[str] = [] - matches = SEARCH_REPLACE_BLOCK_RE.findall(content) - if not matches: - return [content[:500]] if content else [] - - for i, (search_text, replace_text) in enumerate(matches): - if i > 0: - all_diff_lines.append("") # Separator between blocks - search_lines = search_text.strip("\n").split("\n") - replace_lines = replace_text.strip("\n").split("\n") - diff = difflib.unified_diff(search_lines, replace_lines, lineterm="", n=2) - all_diff_lines.extend(list(diff)[2:]) # Skip file headers - - return all_diff_lines - - def render_diff_line(line: str) -> Static: """Render a single diff line with appropriate styling.""" if line.startswith("---") or line.startswith("+++"): @@ -193,28 +179,35 @@ class WriteFileResultWidget(ToolResultWidget[WriteFileResult]): yield from self._footer() -class SearchReplaceApprovalWidget(ToolApprovalWidget[SearchReplaceArgs]): +class EditApprovalWidget(ToolApprovalWidget[EditArgs]): def compose(self) -> ComposeResult: yield NoMarkupStatic( f"File: {self.args.file_path}", classes="approval-description" ) yield NoMarkupStatic("") - diff_lines = parse_search_replace_to_diff(self.args.content) - for line in diff_lines: + old_lines = self.args.old_string.split("\n") + new_lines = self.args.new_string.split("\n") + diff = list(difflib.unified_diff(old_lines, new_lines, lineterm="", n=2))[2:] + for line in diff: yield render_diff_line(line) + if self.args.replace_all: + yield NoMarkupStatic("(replace_all)", classes="approval-description") -class SearchReplaceResultWidget(ToolResultWidget[SearchReplaceResult]): + +class EditResultWidget(ToolResultWidget[EditResult]): def compose(self) -> ComposeResult: if not self.result: yield from self._footer() return for warning in self.warnings: yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning") - if self.result.content: - for line in parse_search_replace_to_diff(self.result.content): - yield render_diff_line(line) + old_lines = self.result.old_string.split("\n") + new_lines = self.result.new_string.split("\n") + diff = list(difflib.unified_diff(old_lines, new_lines, lineterm="", n=2))[2:] + for line in diff: + yield render_diff_line(line) yield from self._footer() @@ -260,10 +253,12 @@ class TodoResultWidget(ToolResultWidget[TodoResult]): return icons.get(status, "☐") -class ReadFileApprovalWidget(ToolApprovalWidget[ReadFileArgs]): +class ReadApprovalWidget(ToolApprovalWidget[ReadArgs]): def compose(self) -> ComposeResult: - yield NoMarkupStatic(f"path: {self.args.path}", classes="approval-description") - if self.args.offset > 0: + yield NoMarkupStatic( + f"file_path: {self.args.file_path}", classes="approval-description" + ) + if self.args.offset is not None: yield NoMarkupStatic( f"offset: {self.args.offset}", classes="approval-description" ) @@ -273,23 +268,24 @@ class ReadFileApprovalWidget(ToolApprovalWidget[ReadFileArgs]): ) -class ReadFileResultWidget(ToolResultWidget[ReadFileResult]): +class ReadResultWidget(ToolResultWidget[ReadResult]): def compose(self) -> ComposeResult: if self.collapsed: yield from self._footer() return if self.result: yield NoMarkupStatic( - f"Path: {self.result.path}", classes="tool-result-detail" + f"Path: {self.result.file_path}", classes="tool-result-detail" ) for warning in self.warnings: yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning") truncation_info = None if self.result and self.result.content: yield NoMarkupStatic("") - ext = Path(self.result.path).suffix.lstrip(".") or "text" - content, truncation_info = _truncate_lines(self.result.content, 10) - yield Markdown(f"```{ext}\n{content}\n```") + content, truncation_info = _truncate_lines( + _strip_line_numbers(self.result.content), 10 + ) + yield NoMarkupStatic(content, classes="tool-result-detail") yield from self._footer(truncation_info) @@ -337,18 +333,18 @@ class AskUserQuestionResultWidget(ToolResultWidget[AskUserQuestionResult]): APPROVAL_WIDGETS: dict[str, type[ToolApprovalWidget]] = { "bash": BashApprovalWidget, - "read_file": ReadFileApprovalWidget, + "read": ReadApprovalWidget, "write_file": WriteFileApprovalWidget, - "search_replace": SearchReplaceApprovalWidget, + "edit": EditApprovalWidget, "grep": GrepApprovalWidget, "todo": TodoApprovalWidget, } RESULT_WIDGETS: dict[str, type[ToolResultWidget]] = { "bash": BashResultWidget, - "read_file": ReadFileResultWidget, + "read": ReadResultWidget, "write_file": WriteFileResultWidget, - "search_replace": SearchReplaceResultWidget, + "edit": EditResultWidget, "grep": GrepResultWidget, "todo": TodoResultWidget, "ask_user_question": AskUserQuestionResultWidget, diff --git a/vibe/cli/textual_ui/windowing/history.py b/vibe/cli/textual_ui/windowing/history.py index cdb743c..a96183f 100644 --- a/vibe/cli/textual_ui/windowing/history.py +++ b/vibe/cli/textual_ui/windowing/history.py @@ -46,10 +46,14 @@ def build_history_widgets( continue match msg.role: case Role.user: - if msg.content: + if msg.content or msg.images: # history_index is 0-based in non-system messages; # agent_loop.messages index = history_index + 1 (system msg at 0) - widget = UserMessage(msg.content, message_index=history_index + 1) + widget = UserMessage( + msg.content or "", + message_index=history_index + 1, + images=msg.images, + ) widgets.append(widget) history_widget_indices[widget] = history_index diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index 505433e..d1d762d 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator, Callable, Generator +from collections.abc import AsyncGenerator, Callable, Generator, Sequence import contextlib import copy from enum import StrEnum, auto @@ -106,6 +106,7 @@ from vibe.core.types import ( CompactEndEvent, CompactStartEvent, ContextTooLongError, + ImageAttachment, LLMChunk, LLMMessage, LLMUsage, @@ -171,6 +172,10 @@ class AgentLoopLLMResponseError(AgentLoopError): """Raised when LLM response is malformed or missing expected data.""" +class ImagesNotSupportedError(AgentLoopError): + """Raised when the active model does not support image attachments.""" + + class TeleportError(AgentLoopError): """Raised when teleport to Vibe Code fails.""" @@ -649,16 +654,24 @@ class AgentLoop: # noqa: PLR0904 client_message_id: str | None = None, *, auto_title: str | None = None, + images: list[ImageAttachment] | None = None, ) -> AsyncGenerator[BaseEvent, None]: + try: + active_model = self.config.get_active_model() + model_name = active_model.name + except ValueError: + active_model = None + model_name = None + if images and active_model is not None and not active_model.supports_images: + raise ImagesNotSupportedError(active_model.alias) self._clean_message_history() self.rewind_manager.create_checkpoint() - try: - model_name = self.config.get_active_model().name - except ValueError: - model_name = None async with agent_span(model=model_name, session_id=self.session_id): async for event in self._conversation_loop( - msg, client_message_id=client_message_id, auto_title=auto_title + msg, + client_message_id=client_message_id, + auto_title=auto_title, + images=images, ): yield event @@ -872,9 +885,13 @@ class AgentLoop: # noqa: PLR0904 client_message_id: str | None = None, *, auto_title: str | None = None, + images: list[ImageAttachment] | None = None, ) -> AsyncGenerator[BaseEvent]: user_message = LLMMessage( - role=Role.user, content=user_msg, message_id=client_message_id + role=Role.user, + content=user_msg, + message_id=client_message_id, + images=images or None, ) self.messages.append(user_message) self.stats.steps += 1 @@ -1317,6 +1334,25 @@ class AgentLoop: # noqa: PLR0904 tool_call_id=tool_call.call_id, ) + def _messages_for_backend(self, active_model: ModelConfig) -> Sequence[LLMMessage]: + if active_model.supports_images: + return self.messages + if not any(m.images for m in self.messages): + return self.messages + return [ + m.model_copy(update={"images": None}) if m.images else m + for m in self.messages + ] + + def count_history_images_unsupported_by_active_model(self) -> int: + try: + active_model = self.config.get_active_model() + except ValueError: + return 0 + if active_model.supports_images: + return 0 + return sum(1 for m in self.messages if m.images) + async def _chat( self, max_tokens: int | None = None, model_override: ModelConfig | None = None ) -> LLMChunk: @@ -1343,7 +1379,7 @@ class AgentLoop: # noqa: PLR0904 start_time = time.perf_counter() result = await self.backend.complete( model=active_model, - messages=self.messages, + messages=self._messages_for_backend(active_model), temperature=active_model.temperature, tools=available_tools, tool_choice=tool_choice, @@ -1408,7 +1444,7 @@ class AgentLoop: # noqa: PLR0904 chunk_agg: LLMChunk | None = None async for chunk in self.backend.complete_streaming( model=active_model, - messages=self.messages, + messages=self._messages_for_backend(active_model), temperature=active_model.temperature, tools=available_tools, tool_choice=tool_choice, diff --git a/vibe/core/agents/models.py b/vibe/core/agents/models.py index 33989a8..c0d8e6b 100644 --- a/vibe/core/agents/models.py +++ b/vibe/core/agents/models.py @@ -94,7 +94,7 @@ class AgentProfile: ) -CHAT_AGENT_TOOLS = ["grep", "read_file", "ask_user_question", "task"] +CHAT_AGENT_TOOLS = ["grep", "read", "ask_user_question", "task"] def _plan_overrides() -> dict[str, Any]: @@ -102,7 +102,7 @@ def _plan_overrides() -> dict[str, Any]: return { "tools": { "write_file": {"permission": "never", "allowlist": [plans_pattern]}, - "search_replace": {"permission": "never", "allowlist": [plans_pattern]}, + "edit": {"permission": "never", "allowlist": [plans_pattern]}, } } @@ -137,7 +137,7 @@ ACCEPT_EDITS = AgentProfile( "base_disabled": ["exit_plan_mode"], "tools": { "write_file": {"permission": "always"}, - "search_replace": {"permission": "always"}, + "edit": {"permission": "always"}, }, }, ) @@ -155,7 +155,7 @@ EXPLORE = AgentProfile( description="Read-only subagent for codebase exploration", safety=AgentSafety.SAFE, agent_type=AgentType.SUBAGENT, - overrides={"enabled_tools": ["grep", "read_file"], "system_prompt_id": "explore"}, + overrides={"enabled_tools": ["grep", "read"], "system_prompt_id": "explore"}, ) LEAN = AgentProfile( diff --git a/vibe/core/autocompletion/path_prompt.py b/vibe/core/autocompletion/path_prompt.py index 996b0b9..dbb9586 100644 --- a/vibe/core/autocompletion/path_prompt.py +++ b/vibe/core/autocompletion/path_prompt.py @@ -5,13 +5,14 @@ from pathlib import Path from typing import Literal from vibe.core.session.title_format import MentionSegment, TextSegment, TitleSegment +from vibe.core.types import IMAGE_EXTENSIONS @dataclass(frozen=True, slots=True) class PathResource: path: Path alias: str - kind: Literal["file", "folder"] + kind: Literal["file", "folder", "image"] @dataclass(frozen=True, slots=True) @@ -80,14 +81,17 @@ def _extract_candidate(message: str, start: int) -> tuple[str | None, int]: def _is_path_char(char: str) -> bool: - return char.isalnum() or char in "._/\\-()[]{}" + return char.isalnum() or char in "._/\\-()[]{}~" def _to_resource(candidate: str, base_dir: Path) -> PathResource | None: if not candidate: return None - candidate_path = Path(candidate) + try: + candidate_path = Path(candidate).expanduser() + except RuntimeError: + return None resolved = ( candidate_path if candidate_path.is_absolute() else base_dir / candidate_path ) @@ -96,7 +100,13 @@ def _to_resource(candidate: str, base_dir: Path) -> PathResource | None: if not resolved.exists(): return None - kind = "folder" if resolved.is_dir() else "file" + kind: Literal["file", "folder", "image"] + if resolved.is_dir(): + kind = "folder" + elif resolved.suffix.lower() in IMAGE_EXTENSIONS: + kind = "image" + else: + kind = "file" return PathResource(path=resolved, alias=candidate, kind=kind) diff --git a/vibe/core/autocompletion/path_prompt_adapter.py b/vibe/core/autocompletion/path_prompt_adapter.py index bb8fcb3..daa571a 100644 --- a/vibe/core/autocompletion/path_prompt_adapter.py +++ b/vibe/core/autocompletion/path_prompt_adapter.py @@ -16,19 +16,41 @@ DEFAULT_MAX_EMBED_BYTES = 256 * 1024 ResourceBlock = dict[str, str | None] +def extract_image_resources(payload: PathPromptPayload) -> list[PathResource]: + return [r for r in payload.resources if r.kind == "image"] + + +def render_path_prompt_from_payload( + payload: PathPromptPayload, + *, + max_embed_bytes: int | None = DEFAULT_MAX_EMBED_BYTES, + skip_images: bool = False, +) -> str: + blocks = _path_prompt_to_content_blocks( + payload, max_embed_bytes=max_embed_bytes, skip_images=skip_images + ) + return _content_blocks_to_prompt_text(blocks) + + def render_path_prompt( message: str, *, base_dir: Path, max_embed_bytes: int | None = DEFAULT_MAX_EMBED_BYTES, + skip_images: bool = False, ) -> str: - payload = build_path_prompt_payload(message, base_dir=base_dir) - blocks = _path_prompt_to_content_blocks(payload, max_embed_bytes=max_embed_bytes) - return _content_blocks_to_prompt_text(blocks) + return render_path_prompt_from_payload( + build_path_prompt_payload(message, base_dir=base_dir), + max_embed_bytes=max_embed_bytes, + skip_images=skip_images, + ) def _path_prompt_to_content_blocks( - payload: PathPromptPayload, *, max_embed_bytes: int | None = DEFAULT_MAX_EMBED_BYTES + payload: PathPromptPayload, + *, + max_embed_bytes: int | None = DEFAULT_MAX_EMBED_BYTES, + skip_images: bool = False, ) -> list[ResourceBlock]: blocks: list[ResourceBlock] = [{"type": "text", "text": payload.prompt_text}] @@ -50,6 +72,19 @@ def _path_prompt_to_content_blocks( "uri": resource.path.as_uri(), "name": resource.alias, }) + case "image": + # Callers that carry images as `LLMMessage.images` sidecar + # attachments (e.g. the Textual TUI) pass skip_images=True to + # avoid duplicating them as text. Other callers (e.g. ACP, + # which has no sidecar plumbing yet) fall back to a + # resource_link so the model still sees the file reference. + if skip_images: + continue + blocks.append({ + "type": "resource_link", + "uri": resource.path.as_uri(), + "name": resource.alias, + }) return blocks diff --git a/vibe/core/config/__init__.py b/vibe/core/config/__init__.py index 6f77f6e..1475ae9 100644 --- a/vibe/core/config/__init__.py +++ b/vibe/core/config/__init__.py @@ -65,6 +65,7 @@ from vibe.core.config.schema import ( WithShallowMerge, WithUnionMerge, ) +from vibe.core.config.vibe_schema import VibeConfigSchema from vibe.core.prompts import MissingPromptFileError __all__ = [ @@ -120,6 +121,7 @@ __all__ = [ "TrustResolutionError", "UntrustedLayerError", "VibeConfig", + "VibeConfigSchema", "WithConcatMerge", "WithConflictMerge", "WithReplaceMerge", diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py index b834784..ccacd5b 100644 --- a/vibe/core/config/_settings.py +++ b/vibe/core/config/_settings.py @@ -7,7 +7,7 @@ from pathlib import Path import re import shlex import tomllib -from typing import Annotated, Any, Literal, get_args +from typing import Annotated, Any, ClassVar, Literal, get_args from urllib.parse import urljoin from dotenv import dotenv_values @@ -30,7 +30,12 @@ from vibe.core.agents.models import BuiltinAgentName from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.logger import logger from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR -from vibe.core.prompts import UtilityPrompt, load_prompt, load_system_prompt +from vibe.core.prompts import ( + SystemPrompt, + UtilityPrompt, + load_prompt, + load_system_prompt, +) from vibe.core.types import Backend from vibe.core.utils import configure_ssl_context, get_server_url_from_api_base @@ -362,6 +367,9 @@ def _default_alias_to_name(data: Any) -> Any: ThinkingLevel = Literal["off", "low", "medium", "high", "max"] THINKING_LEVELS: list[str] = list(get_args(ThinkingLevel)) +DEFAULT_AUTO_COMPACT_THRESHOLD = 200_000 +DEFAULT_API_TIMEOUT = 720.0 + class ModelConfig(BaseModel): name: str @@ -371,8 +379,8 @@ class ModelConfig(BaseModel): input_price: float = 0.0 # Price per million input tokens output_price: float = 0.0 # Price per million output tokens thinking: ThinkingLevel = "off" - auto_compact_threshold: int = 200_000 - + supports_images: bool = False + auto_compact_threshold: int = DEFAULT_AUTO_COMPACT_THRESHOLD _default_alias_to_name = model_validator(mode="before")(_default_alias_to_name) @@ -417,12 +425,15 @@ class OtelSpanExporterConfig(BaseModel): MISTRAL_OTEL_PATH = "/telemetry" -_DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai" +DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai" + +DEFAULT_VIBE_CODE_WORKFLOW_ID = "__shared-nuage-workflow" +DEFAULT_VIBE_CODE_TASK_QUEUE = "shared-vibe-nuage" DEFAULT_PROVIDERS = [ ProviderConfig( name="mistral", - api_base=f"{_DEFAULT_MISTRAL_SERVER_URL}/v1", + api_base=f"{DEFAULT_MISTRAL_SERVER_URL}/v1", api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY, browser_auth_base_url=DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL, browser_auth_api_base_url=DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL, @@ -435,16 +446,19 @@ DEFAULT_PROVIDERS = [ ), ] +DEFAULT_ACTIVE_MODEL_CONFIG = ModelConfig( + name="mistral-vibe-cli-latest", + provider="mistral", + alias="mistral-medium-3.5", + temperature=1.0, + input_price=1.5, + output_price=7.5, + thinking="high", + supports_images=True, +) + DEFAULT_MODELS = [ - ModelConfig( - name="mistral-vibe-cli-latest", - provider="mistral", - alias="mistral-medium-3.5", - temperature=1.0, - input_price=1.5, - output_price=7.5, - thinking="high", - ), + DEFAULT_ACTIVE_MODEL_CONFIG, ModelConfig( name="devstral-small-latest", provider="mistral", @@ -461,8 +475,6 @@ DEFAULT_MODELS = [ ), ] -DEFAULT_ACTIVE_MODEL = DEFAULT_MODELS[0].alias - DEFAULT_TRANSCRIBE_PROVIDERS = [ TranscribeProviderConfig( name="mistral", @@ -471,13 +483,13 @@ DEFAULT_TRANSCRIBE_PROVIDERS = [ ) ] -DEFAULT_TRANSCRIBE_MODELS = [ - TranscribeModelConfig( - name="voxtral-mini-transcribe-realtime-2602", - provider="mistral", - alias="voxtral-realtime", - ) -] +DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG = TranscribeModelConfig( + name="voxtral-mini-transcribe-realtime-2602", + provider="mistral", + alias="voxtral-realtime", +) + +DEFAULT_TRANSCRIBE_MODELS = [DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG] DEFAULT_TTS_PROVIDERS = [ TTSProviderConfig( @@ -487,17 +499,17 @@ DEFAULT_TTS_PROVIDERS = [ ) ] -DEFAULT_TTS_MODELS = [ - TTSModelConfig( - name="voxtral-mini-tts-latest", provider="mistral", alias="voxtral-tts" - ) -] +DEFAULT_ACTIVE_TTS_MODEL_CONFIG = TTSModelConfig( + name="voxtral-mini-tts-latest", provider="mistral", alias="voxtral-tts" +) + +DEFAULT_TTS_MODELS = [DEFAULT_ACTIVE_TTS_MODEL_CONFIG] DEFAULT_THEME = "ansi-dark" class VibeConfig(BaseSettings): - active_model: str = DEFAULT_ACTIVE_MODEL + active_model: str = DEFAULT_ACTIVE_MODEL_CONFIG.alias vim_keybindings: bool = False theme: str = DEFAULT_THEME disable_welcome_banner_animation: bool = False @@ -507,13 +519,13 @@ class VibeConfig(BaseSettings): context_warnings: bool = False voice_mode_enabled: bool = False narrator_enabled: bool = False - active_transcribe_model: str = "voxtral-realtime" - active_tts_model: str = "voxtral-tts" + active_transcribe_model: str = DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG.alias + active_tts_model: str = DEFAULT_ACTIVE_TTS_MODEL_CONFIG.alias bypass_tool_permissions: bool = False enable_telemetry: bool = True experiment_overrides: dict[str, str] = Field(default_factory=dict) - system_prompt_id: str = "cli" - compaction_prompt_id: str = "compact" + system_prompt_id: str = SystemPrompt.CLI + compaction_prompt_id: str = UtilityPrompt.COMPACT include_commit_signature: bool = True include_model_info: bool = True include_project_context: bool = True @@ -522,16 +534,20 @@ class VibeConfig(BaseSettings): enable_auto_update: bool = True enable_notifications: bool = True enable_system_trust_store: bool = False - api_timeout: float = 720.0 - auto_compact_threshold: int = 200_000 + api_timeout: float = DEFAULT_API_TIMEOUT + auto_compact_threshold: int = DEFAULT_AUTO_COMPACT_THRESHOLD vibe_code_enabled: bool = Field(default=True, exclude=True) - vibe_code_base_url: str = Field(default="https://api.mistral.ai", exclude=True) + vibe_code_base_url: str = Field(default=DEFAULT_MISTRAL_SERVER_URL, exclude=True) vibe_code_sessions_base_url: str = Field( default="https://chat.mistral.ai", exclude=True ) - vibe_code_workflow_id: str = Field(default="__shared-nuage-workflow", exclude=True) - vibe_code_api_key_env_var: str = Field(default="MISTRAL_API_KEY", exclude=True) + vibe_code_workflow_id: str = Field( + default=DEFAULT_VIBE_CODE_WORKFLOW_ID, exclude=True + ) + vibe_code_api_key_env_var: str = Field( + default=DEFAULT_MISTRAL_API_ENV_KEY, exclude=True + ) vibe_code_project_name: str | None = Field(default=None, exclude=True) # TODO(otel): remove exclude=True once the feature is publicly available @@ -702,7 +718,7 @@ class VibeConfig(BaseSettings): api_key_env = DEFAULT_MISTRAL_API_ENV_KEY endpoint = urljoin( - f"{urljoin(server_url or _DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_PATH).rstrip('/')}/", + f"{urljoin(server_url or DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_PATH).rstrip('/')}/", traces_export_path, ) @@ -741,6 +757,9 @@ class VibeConfig(BaseSettings): return self.compaction_model return self.get_active_model() + def connectors_by_name(self) -> dict[str, ConnectorConfig]: + return {c.name: c for c in self.connectors} + def get_mistral_provider(self) -> ProviderConfig | None: try: active_provider = self.get_active_provider() @@ -964,14 +983,15 @@ class VibeConfig(BaseSettings): entry["thinking"] = level break else: - # Model comes from defaults; materialize the full list so we + # Model comes from defaults; materialize the identities so we # don't lose the other models. models = [ { - "alias": m.alias, "name": m.name, "provider": m.provider, + "alias": m.alias, "thinking": level if m.alias == model.alias else m.thinking, + **({"supports_images": True} if m.supports_images else {}), } for m in self.models ] @@ -1057,13 +1077,64 @@ class VibeConfig(BaseSettings): model["thinking"] = "high" changed = True + if ( + model.get("name") == "mistral-vibe-cli-latest" + and model.get("alias") == "mistral-medium-3.5" + and "supports_images" not in model + ): + model["supports_images"] = True + changed = True + if data.get("active_model") == "devstral-2": data["active_model"] = "mistral-medium-3.5" changed = True + if cls._migrate_renamed_tools(data): + changed = True + if changed: cls.dump_config(data) + # Old tool name -> new tool name. The new tools replaced these in-place, so + # existing user configs keyed by the old names need their settings moved over. + _RENAMED_TOOLS: ClassVar[dict[str, str]] = { + "read_file": "read", + "search_replace": "edit", + } + # Options on the old tool that have no equivalent on the new one; dropped on migrate. + _DROPPED_TOOL_OPTIONS: ClassVar[dict[str, tuple[str, ...]]] = { + "edit": ("max_content_size", "create_backup") + } + + @classmethod + def _migrate_renamed_tools(cls, data: dict[str, Any]) -> bool: + changed = False + + tools = data.get("tools") + if isinstance(tools, dict): + for old, new in cls._RENAMED_TOOLS.items(): + if old not in tools: + continue + old_config = tools.pop(old) + changed = True + # Prefer an already-present new key; don't clobber it. + if new not in tools: + if isinstance(old_config, dict): + for dropped in cls._DROPPED_TOOL_OPTIONS.get(new, ()): + old_config.pop(dropped, None) + tools[new] = old_config + + for list_key in ("enabled_tools", "disabled_tools"): + names = data.get(list_key) + if not isinstance(names, list): + continue + renamed = [cls._RENAMED_TOOLS.get(name, name) for name in names] + if renamed != names: + data[list_key] = renamed + changed = True + + return changed + @classmethod def load(cls, **overrides: Any) -> VibeConfig: cls._migrate() diff --git a/vibe/core/config/harness_files/_harness_manager.py b/vibe/core/config/harness_files/_harness_manager.py index e3c0c7d..9abff76 100644 --- a/vibe/core/config/harness_files/_harness_manager.py +++ b/vibe/core/config/harness_files/_harness_manager.py @@ -14,9 +14,9 @@ from vibe.core.config.harness_files._paths import ( from vibe.core.paths import ( AGENTS_MD_FILENAME, VIBE_HOME, - ConfigWalkResult, + LocalConfigDirs, dedup_paths, - walk_local_config_dirs, + find_local_config_dirs, ) from vibe.core.trusted_folders import trusted_folders_manager from vibe.core.utils.io import read_safe @@ -60,19 +60,18 @@ class HarnessFilesManager: """Open project directories: trusted cwd (if any) plus ``--add-dir`` paths. - ``--add-dir`` entries are coalesced (resolved + nested-collapse: adding - ``/x`` and ``/x/y`` is the same as just ``/x``). Add-dirs equal to or - nested inside the cwd are dropped — cwd already covers them. The cwd - itself, if trusted, is always kept as a starting point: when an add-dir - contains it, both survive (the add-dir contributes its own root-level - discovery; cwd preserves its walk-up semantics for AGENTS.md). + ``--add-dir`` entries are resolved and deduplicated; nested paths are + preserved because project config discovery is root-level only. + Add-dirs equal to the cwd are dropped (redundant). When an add-dir + contains the cwd, both survive (the add-dir contributes its own + root-level discovery; cwd preserves walk-up semantics for AGENTS.md). """ - add_dirs = dedup_paths(self._additional_dirs, drop_nested=True) + add_dirs = dedup_paths(self._additional_dirs) workdir = self._trusted_workdir if workdir is None: return add_dirs w = workdir.resolve() - return [w, *(p for p in add_dirs if p != w and not p.is_relative_to(w))] + return [w, *(p for p in add_dirs if p != w)] @property def hook_files(self) -> list[Path]: @@ -110,23 +109,23 @@ class HarnessFilesManager: d = GLOBAL_AGENTS_DIR.path return [d] if d.is_dir() else [] - def _walk_project_roots(self) -> ConfigWalkResult: - result = ConfigWalkResult() + def _collect_project_roots(self) -> LocalConfigDirs: + result = LocalConfigDirs() for root in self.project_roots: - result |= walk_local_config_dirs(root) + result |= find_local_config_dirs(root) return result @property def project_tools_dirs(self) -> list[Path]: - return list(self._walk_project_roots().tools) + return list(self._collect_project_roots().tools) @property def project_skills_dirs(self) -> list[Path]: - return list(self._walk_project_roots().skills) + return list(self._collect_project_roots().skills) @property def project_agents_dirs(self) -> list[Path]: - return list(self._walk_project_roots().agents) + return list(self._collect_project_roots().agents) @property def user_config_file(self) -> Path: diff --git a/vibe/core/config/layer.py b/vibe/core/config/layer.py index 4396894..9d8cc01 100644 --- a/vibe/core/config/layer.py +++ b/vibe/core/config/layer.py @@ -7,6 +7,9 @@ from typing import Any from pydantic import BaseModel, ConfigDict +from vibe.core.config.patch import ConfigPatch +from vibe.core.config.types import ConflictStrategy + class RawConfig(BaseModel): """Permissive default schema that preserves all fields as extras.""" @@ -287,7 +290,12 @@ class ConfigLayer[S: BaseModel](ABC): """Return opaque token representing current backing store state.""" raise NotImplementedError - async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None: + async def apply( + self, + patch: ConfigPatch, + *, + on_conflict: ConflictStrategy = ConflictStrategy.CANCEL, + ) -> None: """Persist a patch to this layer's backing store.""" raise NotImplementedError diff --git a/vibe/core/config/layers/__init__.py b/vibe/core/config/layers/__init__.py index eec79b9..0d46f2b 100644 --- a/vibe/core/config/layers/__init__.py +++ b/vibe/core/config/layers/__init__.py @@ -1,7 +1,13 @@ from __future__ import annotations +from vibe.core.config.layers.environment import EnvironmentLayer from vibe.core.config.layers.overrides import OverridesLayer from vibe.core.config.layers.project import ProjectConfigLayer from vibe.core.config.layers.user import UserConfigLayer -__all__ = ["OverridesLayer", "ProjectConfigLayer", "UserConfigLayer"] +__all__ = [ + "EnvironmentLayer", + "OverridesLayer", + "ProjectConfigLayer", + "UserConfigLayer", +] diff --git a/vibe/core/config/layers/environment.py b/vibe/core/config/layers/environment.py new file mode 100644 index 0000000..5719928 --- /dev/null +++ b/vibe/core/config/layers/environment.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, create_model +from pydantic_settings import BaseSettings, SettingsConfigDict + +from vibe.core.config.layer import ConfigLayer, RawConfig + + +class _EnvBase(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="VIBE_", + case_sensitive=False, + env_nested_delimiter="__", + env_ignore_empty=True, + extra="ignore", + ) + + +class EnvironmentLayer(ConfigLayer[RawConfig]): + """Reads VIBE_* env vars via pydantic-settings, which handles type coercion + and validation against the schema. + """ + + def __init__(self, *, name: str = "environment", schema: type[BaseModel]) -> None: + super().__init__(name=name) + + fields: dict[str, Any] = { + field_name: (info.annotation, info) + for field_name, info in schema.model_fields.items() + } + self._settings_class: type[BaseSettings] = create_model( + "_EnvSchema", __base__=_EnvBase, **fields + ) + + async def _check_trust(self) -> bool: + return True + + async def _read_config(self) -> dict[str, Any]: + return self._settings_class().model_dump(exclude_unset=True) + + async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None: + raise NotImplementedError("EnvironmentLayer.apply() is not implemented (M2)") diff --git a/vibe/core/config/layers/overrides.py b/vibe/core/config/layers/overrides.py index 49392a4..5ea7ecf 100644 --- a/vibe/core/config/layers/overrides.py +++ b/vibe/core/config/layers/overrides.py @@ -4,6 +4,8 @@ import copy from typing import Any from vibe.core.config.layer import ConfigLayer, RawConfig +from vibe.core.config.patch import ConfigPatch +from vibe.core.config.types import ConflictStrategy class OverridesLayer(ConfigLayer[RawConfig]): @@ -23,5 +25,10 @@ class OverridesLayer(ConfigLayer[RawConfig]): async def _read_config(self) -> dict[str, Any]: return copy.deepcopy(self._data) - async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None: + async def apply( + self, + patch: ConfigPatch, + *, + on_conflict: ConflictStrategy = ConflictStrategy.CANCEL, + ) -> None: raise NotImplementedError("OverridesLayer.apply() is not implemented (M2)") diff --git a/vibe/core/config/layers/project.py b/vibe/core/config/layers/project.py index 8b26f05..6f2a7ba 100644 --- a/vibe/core/config/layers/project.py +++ b/vibe/core/config/layers/project.py @@ -6,6 +6,8 @@ import tomllib from typing import Any from vibe.core.config.layer import ConfigLayer, RawConfig +from vibe.core.config.patch import ConfigPatch +from vibe.core.config.types import ConflictStrategy from vibe.core.paths._vibe_home import VIBE_HOME from vibe.core.trusted_folders import trusted_folders_manager @@ -64,7 +66,12 @@ class ProjectConfigLayer(ConfigLayer[RawConfig]): await super().revoke_trust() - async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None: + async def apply( + self, + patch: ConfigPatch, + *, + on_conflict: ConflictStrategy = ConflictStrategy.CANCEL, + ) -> None: raise NotImplementedError("ProjectConfigLayer.apply() is not implemented (M2)") async def _find_config_file(self) -> None: diff --git a/vibe/core/config/layers/user.py b/vibe/core/config/layers/user.py index d88a197..d7f50cb 100644 --- a/vibe/core/config/layers/user.py +++ b/vibe/core/config/layers/user.py @@ -5,6 +5,8 @@ import tomllib from typing import Any from vibe.core.config.layer import ConfigLayer, RawConfig +from vibe.core.config.patch import ConfigPatch +from vibe.core.config.types import ConflictStrategy from vibe.core.paths._vibe_home import VIBE_HOME @@ -28,5 +30,10 @@ class UserConfigLayer(ConfigLayer[RawConfig]): with self._path.open("rb") as f: return tomllib.load(f) - async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None: + async def apply( + self, + patch: ConfigPatch, + *, + on_conflict: ConflictStrategy = ConflictStrategy.CANCEL, + ) -> None: raise NotImplementedError("UserConfigLayer.apply() is not implemented (M2)") diff --git a/vibe/core/config/orchestrator.py b/vibe/core/config/orchestrator.py index b4af9c4..6b99d27 100644 --- a/vibe/core/config/orchestrator.py +++ b/vibe/core/config/orchestrator.py @@ -4,7 +4,9 @@ from typing import Any from vibe.core.config.builder import ConfigBuilder from vibe.core.config.layer import ConfigLayer, RawConfig +from vibe.core.config.patch import ConfigPatch from vibe.core.config.schema import ConfigSchema +from vibe.core.config.types import ConflictStrategy class ConfigOrchestrator[S: ConfigSchema]: @@ -39,7 +41,12 @@ class ConfigOrchestrator[S: ConfigSchema]: """Force-reload all layers and atomically replace the config snapshot.""" self._config = await self._builder.build(force_load=True) - async def apply_patch(self, patch: Any) -> None: + async def apply_patch( + self, + patch: ConfigPatch, + *, + on_conflict: ConflictStrategy = ConflictStrategy.CANCEL, + ) -> None: raise NotImplementedError("apply_patch() is not implemented (M2)") async def subscribe(self, callback: Any) -> None: diff --git a/vibe/core/config/types.py b/vibe/core/config/types.py index 5644beb..cfd39cd 100644 --- a/vibe/core/config/types.py +++ b/vibe/core/config/types.py @@ -1,8 +1,25 @@ from __future__ import annotations from dataclasses import dataclass +from enum import StrEnum, auto @dataclass(frozen=True, slots=True) class ConfigOrigin: layer_name: str + + +class ConflictStrategy(StrEnum): + CANCEL = auto() # abort on conflict (default) + REPLACE = auto() # force-overwrite, discard external changes + + +class ConcurrencyConflictError(Exception): + """Raised when backing store was modified externally between load and apply.""" + + def __init__(self, expected_fp: str, actual_fp: str) -> None: + super().__init__( + f"Backing store was modified externally (expected fingerprint '{expected_fp}', got '{actual_fp}')" + ) + self.expected_fp = expected_fp + self.actual_fp = actual_fp diff --git a/vibe/core/config/vibe_schema.py b/vibe/core/config/vibe_schema.py new file mode 100644 index 0000000..1542c7a --- /dev/null +++ b/vibe/core/config/vibe_schema.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Annotated, Any + +from pydantic import Field + +from vibe.core.agents.models import BuiltinAgentName +from vibe.core.config._settings import ( + DEFAULT_ACTIVE_MODEL_CONFIG, + DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG, + DEFAULT_ACTIVE_TTS_MODEL_CONFIG, + DEFAULT_API_TIMEOUT, + DEFAULT_AUTO_COMPACT_THRESHOLD, + DEFAULT_CONSOLE_BASE_URL, + DEFAULT_MISTRAL_API_ENV_KEY, + DEFAULT_MISTRAL_SERVER_URL, + DEFAULT_MODELS, + DEFAULT_PROVIDERS, + DEFAULT_THEME, + DEFAULT_TRANSCRIBE_MODELS, + DEFAULT_TRANSCRIBE_PROVIDERS, + DEFAULT_TTS_MODELS, + DEFAULT_TTS_PROVIDERS, + DEFAULT_VIBE_BASE_URL, + DEFAULT_VIBE_CODE_TASK_QUEUE, + DEFAULT_VIBE_CODE_WORKFLOW_ID, + ConnectorConfig, + ExperimentsConfig, + MCPServer, + ModelConfig, + ProjectContextConfig, + ProviderConfig, + SessionLoggingConfig, + TranscribeModelConfig, + TranscribeProviderConfig, + TTSModelConfig, + TTSProviderConfig, +) +from vibe.core.config.schema import ( + ConfigSchema, + WithConcatMerge, + WithReplaceMerge, + WithShallowMerge, + WithUnionMerge, +) +from vibe.core.prompts import SystemPrompt, UtilityPrompt + + +class VibeConfigSchema(ConfigSchema): + # Models + active_model: Annotated[str, WithReplaceMerge()] = DEFAULT_ACTIVE_MODEL_CONFIG.alias + providers: Annotated[list[ProviderConfig], WithUnionMerge(merge_key="name")] = ( + Field(default_factory=lambda: list(DEFAULT_PROVIDERS)) + ) + models: Annotated[list[ModelConfig], WithUnionMerge(merge_key="alias")] = Field( + default_factory=lambda: list(DEFAULT_MODELS) + ) + compaction_model: Annotated[ModelConfig | None, WithReplaceMerge()] = None + auto_compact_threshold: Annotated[int, WithReplaceMerge()] = ( + DEFAULT_AUTO_COMPACT_THRESHOLD + ) + active_transcribe_model: Annotated[str, WithReplaceMerge()] = ( + DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG.alias + ) + transcribe_providers: Annotated[ + list[TranscribeProviderConfig], WithUnionMerge(merge_key="name") + ] = Field(default_factory=lambda: list(DEFAULT_TRANSCRIBE_PROVIDERS)) + transcribe_models: Annotated[ + list[TranscribeModelConfig], WithUnionMerge(merge_key="alias") + ] = Field(default_factory=lambda: list(DEFAULT_TRANSCRIBE_MODELS)) + active_tts_model: Annotated[str, WithReplaceMerge()] = ( + DEFAULT_ACTIVE_TTS_MODEL_CONFIG.alias + ) + tts_providers: Annotated[ + list[TTSProviderConfig], WithUnionMerge(merge_key="name") + ] = Field(default_factory=lambda: list(DEFAULT_TTS_PROVIDERS)) + tts_models: Annotated[list[TTSModelConfig], WithUnionMerge(merge_key="alias")] = ( + Field(default_factory=lambda: list(DEFAULT_TTS_MODELS)) + ) + + # Tools + tools: Annotated[dict[str, dict[str, Any]], WithShallowMerge()] = Field( + default_factory=dict + ) + tool_paths: Annotated[list[Path], WithConcatMerge()] = Field( + default_factory=list, + description=( + "Additional directories or files to explore for custom tools. " + "Paths may be absolute or relative to the current working directory. " + "Directories are shallow-searched for tool definition files, " + "while files are loaded directly if valid." + ), + ) + enabled_tools: Annotated[list[str], WithConcatMerge()] = Field( + default_factory=list, + description=( + "An explicit list of tool names/patterns to enable. If set, only these" + " tools will be active. Supports glob patterns (e.g., 'serena_*') and" + " regex with 're:' prefix (e.g., 're:^serena_.*')." + ), + ) + disabled_tools: Annotated[list[str], WithConcatMerge()] = Field( + default_factory=list, + description=( + "A list of tool names/patterns to disable. Ignored if 'enabled_tools'" + " is set. Supports glob patterns and regex with 're:' prefix." + ), + ) + mcp_servers: Annotated[list[MCPServer], WithUnionMerge(merge_key="name")] = Field( + default_factory=list, description="Preferred MCP server configuration entries." + ) + enable_connectors: Annotated[bool, WithReplaceMerge()] = True + connectors: Annotated[list[ConnectorConfig], WithUnionMerge(merge_key="name")] = ( + Field( + default_factory=list, + description="Per-connector settings (disable, disabled_tools).", + ) + ) + + # Agents + agent_paths: Annotated[list[Path], WithConcatMerge()] = Field( + default_factory=list, + description=( + "Additional directories to search for custom agent profiles. " + "Each path may be absolute or relative to the current working directory." + ), + ) + enabled_agents: Annotated[list[str], WithConcatMerge()] = Field( + default_factory=list, + description=( + "An explicit list of agent names/patterns to enable. If set, only these" + " agents will be available. Supports glob patterns (e.g., 'custom-*')" + " and regex with 're:' prefix." + ), + ) + disabled_agents: Annotated[list[str], WithConcatMerge()] = Field( + default_factory=list, + description=( + "A list of agent names/patterns to disable. Ignored if 'enabled_agents'" + " is set. Supports glob patterns and regex with 're:' prefix." + ), + ) + installed_agents: Annotated[list[str], WithConcatMerge()] = Field( + default_factory=list, + description=( + "A list of opt-in builtin agent names that have been explicitly installed." + ), + ) + default_agent: Annotated[str, WithReplaceMerge()] = Field( + default=BuiltinAgentName.DEFAULT, + description=( + "Agent profile to use when no --agent flag is passed. " + "Builtin: default, plan, accept-edits, auto-approve. " + "Applies in both interactive and programmatic (-p/--prompt) mode." + ), + ) + + # Skills + skill_paths: Annotated[list[Path], WithConcatMerge()] = Field( + default_factory=list, + description=( + "Additional directories to search for skills. " + "Each path may be absolute or relative to the current working directory." + ), + ) + enabled_skills: Annotated[list[str], WithConcatMerge()] = Field( + default_factory=list, + description=( + "An explicit list of skill names/patterns to enable. If set, only these" + " skills will be active. Supports glob patterns (e.g., 'search-*') and" + " regex with 're:' prefix." + ), + ) + disabled_skills: Annotated[list[str], WithConcatMerge()] = Field( + default_factory=list, + description=( + "A list of skill names/patterns to disable. Ignored if 'enabled_skills'" + " is set. Supports glob patterns and regex with 're:' prefix." + ), + ) + + # Internal + vibe_code_enabled: Annotated[bool, WithReplaceMerge()] = True + vibe_code_base_url: Annotated[str, WithReplaceMerge()] = DEFAULT_MISTRAL_SERVER_URL + vibe_code_workflow_id: Annotated[str, WithReplaceMerge()] = ( + DEFAULT_VIBE_CODE_WORKFLOW_ID + ) + vibe_code_task_queue: Annotated[str | None, WithReplaceMerge()] = ( + DEFAULT_VIBE_CODE_TASK_QUEUE + ) + vibe_code_api_key_env_var: Annotated[str, WithReplaceMerge()] = ( + DEFAULT_MISTRAL_API_ENV_KEY + ) + vibe_code_project_name: Annotated[str | None, WithReplaceMerge()] = None + vibe_code_experimental_nuage_enabled: Annotated[bool, WithReplaceMerge()] = False + enable_otel: Annotated[bool, WithReplaceMerge()] = False + otel_endpoint: Annotated[str, WithReplaceMerge()] = "" + console_base_url: Annotated[str, WithReplaceMerge()] = DEFAULT_CONSOLE_BASE_URL + enable_experimental_hooks: Annotated[bool, WithReplaceMerge()] = False + + # Top-level scalars + theme: Annotated[str, WithReplaceMerge()] = DEFAULT_THEME + experiment_overrides: Annotated[dict[str, str], WithReplaceMerge()] = Field( + default_factory=dict + ) + vim_keybindings: Annotated[bool, WithReplaceMerge()] = False + disable_welcome_banner_animation: Annotated[bool, WithReplaceMerge()] = False + autocopy_to_clipboard: Annotated[bool, WithReplaceMerge()] = True + file_watcher_for_autocomplete: Annotated[bool, WithReplaceMerge()] = False + displayed_workdir: Annotated[str, WithReplaceMerge()] = "" + context_warnings: Annotated[bool, WithReplaceMerge()] = False + voice_mode_enabled: Annotated[bool, WithReplaceMerge()] = False + narrator_enabled: Annotated[bool, WithReplaceMerge()] = False + bypass_tool_permissions: Annotated[bool, WithReplaceMerge()] = False + enable_telemetry: Annotated[bool, WithReplaceMerge()] = True + system_prompt_id: Annotated[str, WithReplaceMerge()] = SystemPrompt.CLI + compaction_prompt_id: Annotated[str, WithReplaceMerge()] = UtilityPrompt.COMPACT + include_commit_signature: Annotated[bool, WithReplaceMerge()] = True + include_model_info: Annotated[bool, WithReplaceMerge()] = True + include_project_context: Annotated[bool, WithReplaceMerge()] = True + include_prompt_detail: Annotated[bool, WithReplaceMerge()] = True + enable_update_checks: Annotated[bool, WithReplaceMerge()] = True + enable_auto_update: Annotated[bool, WithReplaceMerge()] = True + enable_notifications: Annotated[bool, WithReplaceMerge()] = True + enable_system_trust_store: Annotated[bool, WithReplaceMerge()] = False + api_timeout: Annotated[float, WithReplaceMerge()] = DEFAULT_API_TIMEOUT + vibe_base_url: Annotated[str, WithReplaceMerge()] = DEFAULT_VIBE_BASE_URL + vibe_code_sessions_base_url: Annotated[str, WithReplaceMerge()] = ( + "https://chat.mistral.ai" + ) + + # Nested configs (REPLACE — simple nested models, no merge semantics) + project_context: Annotated[ProjectContextConfig, WithReplaceMerge()] = Field( + default_factory=ProjectContextConfig + ) + session_logging: Annotated[SessionLoggingConfig, WithReplaceMerge()] = Field( + default_factory=SessionLoggingConfig + ) + experiments: Annotated[ExperimentsConfig, WithReplaceMerge()] = Field( + default_factory=ExperimentsConfig + ) diff --git a/vibe/core/llm/backend/_image.py b/vibe/core/llm/backend/_image.py new file mode 100644 index 0000000..990d908 --- /dev/null +++ b/vibe/core/llm/backend/_image.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import base64 +from functools import lru_cache +from pathlib import Path + +from vibe.core.types import ImageAttachment + + +class ImageReadError(Exception): + pass + + +_CACHE_MAX = 32 + + +@lru_cache(maxsize=_CACHE_MAX) +def _encode_cached(path_str: str, mtime_ns: int, size: int) -> str: + try: + return base64.b64encode(Path(path_str).read_bytes()).decode("ascii") + except OSError as e: + raise ImageReadError(f"Failed to read image {path_str}: {e}") from e + + +def _encode(att: ImageAttachment) -> str: + try: + stat = att.path.stat() + except OSError as e: + raise ImageReadError(f"Failed to stat image {att.path}: {e}") from e + return _encode_cached(str(att.path), stat.st_mtime_ns, stat.st_size) + + +def to_data_uri(att: ImageAttachment) -> str: + return f"data:{att.mime_type};base64,{_encode(att)}" + + +def to_base64(att: ImageAttachment) -> str: + return _encode(att) diff --git a/vibe/core/llm/backend/anthropic.py b/vibe/core/llm/backend/anthropic.py index 8c3241a..263713b 100644 --- a/vibe/core/llm/backend/anthropic.py +++ b/vibe/core/llm/backend/anthropic.py @@ -6,6 +6,7 @@ import re from typing import Any, ClassVar from vibe.core.config import ProviderConfig +from vibe.core.llm.backend._image import to_base64 as _to_base64 from vibe.core.llm.backend.base import APIAdapter, PreparedRequest from vibe.core.types import ( AvailableTool, @@ -36,6 +37,18 @@ class AnthropicMapper: user_content: list[dict[str, Any]] = [] if msg.content: user_content.append({"type": "text", "text": msg.content}) + if msg.images: + user_content.extend( + { + "type": "image", + "source": { + "type": "base64", + "media_type": att.mime_type, + "data": _to_base64(att), + }, + } + for att in msg.images + ) converted.append({"role": "user", "content": user_content or ""}) case Role.assistant: converted.append(self._convert_assistant_message(msg)) diff --git a/vibe/core/llm/backend/generic.py b/vibe/core/llm/backend/generic.py index 12f6380..67773ac 100644 --- a/vibe/core/llm/backend/generic.py +++ b/vibe/core/llm/backend/generic.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple import httpx +from vibe.core.llm.backend._image import to_data_uri as _to_data_uri from vibe.core.llm.backend.anthropic import AnthropicAdapter from vibe.core.llm.backend.base import APIAdapter, PreparedRequest from vibe.core.llm.backend.openai_responses import OpenAIResponsesAdapter @@ -79,6 +80,22 @@ class OpenAIAdapter(APIAdapter): msg_dict["reasoning_content"] = msg_dict.pop(field_name) return msg_dict + def _user_with_images_to_parts( + self, msg_dict: dict[str, Any], source: LLMMessage + ) -> dict[str, Any]: + if source.role != Role.user or not source.images: + return msg_dict + parts: list[dict[str, Any]] = [] + text = msg_dict.get("content") + if isinstance(text, str) and text: + parts.append({"type": "text", "text": text}) + parts.extend( + {"type": "image_url", "image_url": {"url": _to_data_uri(att)}} + for att in source.images + ) + msg_dict["content"] = parts + return msg_dict + def prepare_request( self, *, @@ -95,17 +112,21 @@ class OpenAIAdapter(APIAdapter): ) -> PreparedRequest: field_name = provider.reasoning_field_name converted_messages = [ - self._reasoning_to_api( - msg.model_dump( - exclude_none=True, - exclude={ - "message_id", - "reasoning_message_id", - "reasoning_state", - "injected", - }, + self._user_with_images_to_parts( + self._reasoning_to_api( + msg.model_dump( + exclude_none=True, + exclude={ + "message_id", + "reasoning_message_id", + "reasoning_state", + "injected", + "images", + }, + ), + field_name, ), - field_name, + msg, ) for msg in messages ] diff --git a/vibe/core/llm/backend/mistral.py b/vibe/core/llm/backend/mistral.py index 9af363d..6c2a126 100644 --- a/vibe/core/llm/backend/mistral.py +++ b/vibe/core/llm/backend/mistral.py @@ -19,6 +19,8 @@ from mistralai.client.models import ( Function, FunctionCall as MistralFunctionCall, FunctionName, + ImageURL, + ImageURLChunk, SystemMessage, TextChunk, ThinkChunk, @@ -31,6 +33,7 @@ from mistralai.client.models import ( ) from mistralai.client.utils.retries import BackoffStrategy, RetryConfig +from vibe.core.llm.backend._image import to_data_uri as _to_data_uri from vibe.core.llm.exceptions import BackendErrorBuilder from vibe.core.types import ( AvailableTool, @@ -61,6 +64,17 @@ class MistralMapper: case Role.system: return SystemMessage(role="system", content=msg.content or "") case Role.user: + if msg.images: + user_parts: list[ContentChunk] = [] + if msg.content: + user_parts.append(TextChunk(type="text", text=msg.content)) + user_parts.extend( + ImageURLChunk( + type="image_url", image_url=ImageURL(url=_to_data_uri(att)) + ) + for att in msg.images + ) + return UserMessage(role="user", content=user_parts) return UserMessage(role="user", content=msg.content) case Role.assistant: content: AssistantMessageContent diff --git a/vibe/core/llm/backend/openai_responses.py b/vibe/core/llm/backend/openai_responses.py index 5de4b80..9ee72af 100644 --- a/vibe/core/llm/backend/openai_responses.py +++ b/vibe/core/llm/backend/openai_responses.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, TypedDict, cast from pydantic import TypeAdapter +from vibe.core.llm.backend._image import to_data_uri as _to_data_uri from vibe.core.llm.backend.base import APIAdapter, PreparedRequest from vibe.core.types import ( AvailableTool, @@ -434,7 +435,20 @@ class OpenAIResponsesAdapter(APIAdapter): input_items.append({"role": "system", "content": msg.content or ""}) case Role.user: - input_items.append({"role": "user", "content": msg.content or ""}) + if msg.images: + parts: list[dict[str, Any]] = [] + if msg.content: + parts.append({"type": "input_text", "text": msg.content}) + parts.extend( + {"type": "input_image", "image_url": _to_data_uri(att)} + for att in msg.images + ) + input_items.append({"role": "user", "content": parts}) + else: + input_items.append({ + "role": "user", + "content": msg.content or "", + }) case Role.assistant: for encrypted_content in msg.reasoning_state or []: diff --git a/vibe/core/llm/backend/reasoning_adapter.py b/vibe/core/llm/backend/reasoning_adapter.py index b922ee0..34ff031 100644 --- a/vibe/core/llm/backend/reasoning_adapter.py +++ b/vibe/core/llm/backend/reasoning_adapter.py @@ -5,6 +5,7 @@ import json from typing import Any, ClassVar from vibe.core.config import ProviderConfig +from vibe.core.llm.backend._image import to_data_uri as _to_data_uri from vibe.core.llm.backend.base import APIAdapter, PreparedRequest from vibe.core.types import ( AvailableTool, @@ -26,6 +27,15 @@ class ReasoningAdapter(APIAdapter): case Role.system: return {"role": "system", "content": msg.content or ""} case Role.user: + if msg.images: + parts: list[dict[str, Any]] = [] + if msg.content: + parts.append({"type": "text", "text": msg.content}) + parts.extend( + {"type": "image_url", "image_url": {"url": _to_data_uri(att)}} + for att in msg.images + ) + return {"role": "user", "content": parts} return {"role": "user", "content": msg.content or ""} case Role.assistant: return self._convert_assistant_message(msg) diff --git a/vibe/core/middleware.py b/vibe/core/middleware.py index f25998b..d55f94c 100644 --- a/vibe/core/middleware.py +++ b/vibe/core/middleware.py @@ -151,7 +151,7 @@ def make_plan_agent_reminder( has_exit_plan_mode: bool = True, ) -> str: instructions = [ - "Research the user's query using read-only tools (grep, read_file, etc.)" + "Research the user's query using read-only tools (grep, read, etc.)" ] if has_ask_user_question: instructions.append( @@ -171,7 +171,7 @@ def make_plan_agent_reminder( return f"""<{VIBE_WARNING_TAG}>Plan mode is active. You MUST NOT make any edits (except to the plan file below, or in your scratchpad), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received. ## Plan File Info -Create or edit your plan at {plan_file_path} using the write_file and search_replace tools. +Create or edit your plan at {plan_file_path} using the write_file and edit tools. Build your plan incrementally by writing to or editing this file. This is the only file you are allowed to edit. Make sure to create it early and edit as soon as you internally update your plan. @@ -184,7 +184,7 @@ PLAN_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Plan mode has ended. If you have a pla CHAT_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Chat mode is active. The user wants to have a conversation -- ask questions, get explanations, or discuss code and architecture. You MUST NOT make any edits, run any non-readonly tools, or otherwise make any changes to the system. This supersedes any other instructions you have received. Instead, you should: 1. Answer the user's questions directly and comprehensively 2. Explain code, concepts, or architecture as requested -3. Use read-only tools (grep, read_file) to look up relevant code when needed +3. Use read-only tools (grep, read) to look up relevant code when needed 4. Focus on being informative and conversational -- your response IS the deliverable, not a precursor to action""" CHAT_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Chat mode has ended. You can now use editing tools and make changes to the system.""" diff --git a/vibe/core/nuage/remote_events_source.py b/vibe/core/nuage/remote_events_source.py index 6047f22..71cb074 100644 --- a/vibe/core/nuage/remote_events_source.py +++ b/vibe/core/nuage/remote_events_source.py @@ -40,7 +40,7 @@ class RemoteEventsSource: self._next_start_seq = 0 self._client: WorkflowsClient | None = None self._translator = RemoteWorkflowEventTranslator( - available_tools=self._tool_manager._available, + available_tools=self._tool_manager._all_tools, stats=self.stats, merge_message=self._merge_message, ) diff --git a/vibe/core/nuage/remote_workflow_event_translator.py b/vibe/core/nuage/remote_workflow_event_translator.py index 933491f..6931fe3 100644 --- a/vibe/core/nuage/remote_workflow_event_translator.py +++ b/vibe/core/nuage/remote_workflow_event_translator.py @@ -822,11 +822,7 @@ class RemoteWorkflowEventTranslator: if not ui_state.operations: return {} op = ui_state.operations[0] - return { - "path": op.uri, - "content": op.content, - "overwrite": op.type == "replace", - } + return {"path": op.uri, "content": op.content} if isinstance(ui_state, CommandUIState): return {"command": ui_state.command} if isinstance(ui_state, GenericToolUIState): @@ -853,7 +849,6 @@ class RemoteWorkflowEventTranslator: return { "path": op.uri, "bytes_written": len(op.content.encode()), - "file_existed": op.type == "replace", "content": op.content, }, None diff --git a/vibe/core/paths/__init__.py b/vibe/core/paths/__init__.py index 4c80c63..a5b3e6b 100644 --- a/vibe/core/paths/__init__.py +++ b/vibe/core/paths/__init__.py @@ -1,11 +1,10 @@ from __future__ import annotations from vibe.core.paths._agents_home import AGENTS_HOME -from vibe.core.paths._local_config_walk import ( - WALK_MAX_DEPTH, - ConfigWalkResult, +from vibe.core.paths._local_config_files import ( + LocalConfigDirs, dedup_paths, - walk_local_config_dirs, + find_local_config_dirs, ) from vibe.core.paths._vibe_home import ( CACHE_FILE, @@ -35,9 +34,8 @@ __all__ = [ "SESSION_LOG_DIR", "TRUSTED_FOLDERS_FILE", "VIBE_HOME", - "WALK_MAX_DEPTH", - "ConfigWalkResult", "GlobalPath", + "LocalConfigDirs", "dedup_paths", - "walk_local_config_dirs", + "find_local_config_dirs", ] diff --git a/vibe/core/paths/_local_config_files.py b/vibe/core/paths/_local_config_files.py new file mode 100644 index 0000000..076a589 --- /dev/null +++ b/vibe/core/paths/_local_config_files.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path + + +def dedup_paths(paths: Iterable[Path]) -> list[Path]: + """Resolve and dedup paths, preserving first-occurrence order.""" + resolved = [p.resolve() for p in paths] + return [p for i, p in enumerate(resolved) if p not in resolved[:i]] + + +_VIBE_DIR = Path(".vibe") +_TOOLS_SUBDIR = _VIBE_DIR / "tools" +_VIBE_SKILLS_SUBDIR = _VIBE_DIR / "skills" +_AGENTS_SUBDIR = _VIBE_DIR / "agents" +_AGENTS_DIR = Path(".agents") +_AGENTS_SKILLS_SUBDIR = _AGENTS_DIR / "skills" + + +@dataclass(frozen=True) +class LocalConfigDirs: + """Local config directories discovered at a project root.""" + + config_dirs: tuple[Path, ...] = () + tools: tuple[Path, ...] = () + skills: tuple[Path, ...] = () + agents: tuple[Path, ...] = () + + def __or__(self, other: LocalConfigDirs) -> LocalConfigDirs: + return LocalConfigDirs( + config_dirs=tuple(dedup_paths([*self.config_dirs, *other.config_dirs])), + tools=tuple(dedup_paths([*self.tools, *other.tools])), + skills=tuple(dedup_paths([*self.skills, *other.skills])), + agents=tuple(dedup_paths([*self.agents, *other.agents])), + ) + + +def find_local_config_dirs(root: Path) -> LocalConfigDirs: + """Inspect *root* for ``.vibe/`` and ``.agents/`` config directories. + + Only the root itself is examined — no recursion into subdirectories. + """ + resolved = root.resolve() + config_dirs: list[Path] = [] + tools: list[Path] = [] + skills: list[Path] = [] + agents: list[Path] = [] + + vibe_dir = resolved / _VIBE_DIR + if vibe_dir.is_dir(): + has_content = False + if (candidate := resolved / _TOOLS_SUBDIR).is_dir(): + tools.append(candidate) + has_content = True + if (candidate := resolved / _VIBE_SKILLS_SUBDIR).is_dir(): + skills.append(candidate) + has_content = True + if (candidate := resolved / _AGENTS_SUBDIR).is_dir(): + agents.append(candidate) + has_content = True + if ( + has_content + or (vibe_dir / "prompts").is_dir() + or (vibe_dir / "config.toml").is_file() + ): + config_dirs.append(vibe_dir) + + agents_dir = resolved / _AGENTS_DIR + if agents_dir.is_dir() and (candidate := resolved / _AGENTS_SKILLS_SUBDIR).is_dir(): + skills.append(candidate) + config_dirs.append(agents_dir) + + return LocalConfigDirs( + config_dirs=tuple(config_dirs), + tools=tuple(tools), + skills=tuple(skills), + agents=tuple(agents), + ) diff --git a/vibe/core/paths/_local_config_walk.py b/vibe/core/paths/_local_config_walk.py deleted file mode 100644 index a632c12..0000000 --- a/vibe/core/paths/_local_config_walk.py +++ /dev/null @@ -1,168 +0,0 @@ -from __future__ import annotations - -from collections import deque -from collections.abc import Iterable -from dataclasses import dataclass, field -from functools import cache -import logging -import os -from pathlib import Path - -from vibe.core.autocompletion.file_indexer.ignore_rules import WALK_SKIP_DIR_NAMES - - -def dedup_paths(paths: Iterable[Path], *, drop_nested: bool = False) -> list[Path]: - """Resolve and dedup paths, preserving first-occurrence order. - - With ``drop_nested=True``, also drop paths contained inside another path - (so adding ``/x`` and ``/x/y`` collapses to just ``/x``). - """ - resolved = [p.resolve() for p in paths] - return [ - p - for i, p in enumerate(resolved) - if p not in resolved[:i] - and not (drop_nested and any(p != q and p.is_relative_to(q) for q in resolved)) - ] - - -logger = logging.getLogger("vibe") - -_VIBE_DIR = ".vibe" -_TOOLS_SUBDIR = Path(_VIBE_DIR) / "tools" -_VIBE_SKILLS_SUBDIR = Path(_VIBE_DIR) / "skills" -_AGENTS_SUBDIR = Path(_VIBE_DIR) / "agents" -_AGENTS_DIR = ".agents" -_AGENTS_SKILLS_SUBDIR = Path(_AGENTS_DIR) / "skills" - -WALK_MAX_DEPTH = 4 -_MAX_DIRS = 2000 - - -@dataclass(frozen=True) -class ConfigWalkResult: - """Aggregated results of a config directory walk.""" - - config_dirs: tuple[Path, ...] = () - tools: tuple[Path, ...] = () - skills: tuple[Path, ...] = () - agents: tuple[Path, ...] = () - - def __or__(self, other: ConfigWalkResult) -> ConfigWalkResult: - return ConfigWalkResult( - config_dirs=tuple(dedup_paths([*self.config_dirs, *other.config_dirs])), - tools=tuple(dedup_paths([*self.tools, *other.tools])), - skills=tuple(dedup_paths([*self.skills, *other.skills])), - agents=tuple(dedup_paths([*self.agents, *other.agents])), - ) - - -@dataclass -class _ConfigWalkCollector: - """Mutable accumulator used during BFS, frozen into ConfigWalkResult at the end.""" - - config_dirs: list[Path] = field(default_factory=list) - tools: list[Path] = field(default_factory=list) - skills: list[Path] = field(default_factory=list) - agents: list[Path] = field(default_factory=list) - - def freeze(self) -> ConfigWalkResult: - return ConfigWalkResult( - config_dirs=tuple(self.config_dirs), - tools=tuple(self.tools), - skills=tuple(self.skills), - agents=tuple(self.agents), - ) - - -def _collect_at( - path: Path, entry_names: set[str], collector: _ConfigWalkCollector -) -> None: - """Check a single directory for .vibe/ and .agents/ config subdirs.""" - if _VIBE_DIR in entry_names and (vibe_dir := path / _VIBE_DIR).is_dir(): - has_content = False - if (candidate := path / _TOOLS_SUBDIR).is_dir(): - collector.tools.append(candidate) - has_content = True - if (candidate := path / _VIBE_SKILLS_SUBDIR).is_dir(): - collector.skills.append(candidate) - has_content = True - if (candidate := path / _AGENTS_SUBDIR).is_dir(): - collector.agents.append(candidate) - has_content = True - if ( - has_content - or (vibe_dir / "prompts").is_dir() - or (vibe_dir / "config.toml").is_file() - ): - collector.config_dirs.append(vibe_dir) - if _AGENTS_DIR in entry_names and (agents_dir := path / _AGENTS_DIR).is_dir(): - if (candidate := path / _AGENTS_SKILLS_SUBDIR).is_dir(): - collector.skills.append(candidate) - collector.config_dirs.append(agents_dir) - - -def _scandir_entries(path: Path) -> tuple[set[str], list[Path]]: - """Scan a directory, returning entry names and sorted child directories to descend into. - - Uses ``os.scandir`` so that ``DirEntry.is_dir()`` leverages the dirent - d_type field and avoids a separate ``stat`` syscall on most filesystems. - """ - try: - entries = list(os.scandir(path)) - except OSError: - return set(), [] - - entry_names = {e.name for e in entries} - children: list[Path] = [] - for entry in entries: - name = entry.name - if name in WALK_SKIP_DIR_NAMES or name.startswith("."): - continue - try: - if entry.is_dir(): - children.append(path / name) - except OSError: - continue - children.sort() - return entry_names, children - - -@cache -def walk_local_config_dirs( - root: Path, *, max_depth: int = WALK_MAX_DEPTH, max_dirs: int = _MAX_DIRS -) -> ConfigWalkResult: - """Discover .vibe/ and .agents/ config directories under *root*. - - Uses breadth-first search bounded by *max_depth* and *max_dirs* - to avoid unbounded traversal in large repositories. - - Returns a ``ConfigWalkResult`` containing both the parent config dirs - (for trust decisions) and the categorised subdirs (for loading). - """ - collector = _ConfigWalkCollector() - resolved_root = root.resolve() - queue: deque[tuple[Path, int]] = deque([(resolved_root, 0)]) - visited = 0 - - while queue and visited < max_dirs: - current, depth = queue.popleft() - visited += 1 - - entry_names, children = _scandir_entries(current) - if not entry_names: - continue - - _collect_at(current, entry_names, collector) - - if depth < max_depth: - queue.extend((child, depth + 1) for child in children) - - if visited >= max_dirs: - logger.warning( - "Config directory scan reached directory limit (%d dirs) at %s", - max_dirs, - resolved_root, - ) - - return collector.freeze() diff --git a/vibe/core/prompts/cli_2026-05.md b/vibe/core/prompts/cli_2026-05.md index 0cd29cd..36c48c8 100644 --- a/vibe/core/prompts/cli_2026-05.md +++ b/vibe/core/prompts/cli_2026-05.md @@ -76,7 +76,7 @@ When editing: - Match existing style (indentation, naming, error handling density). - Minimal diff. Remove completely when removing — no `_unused` renames, no `// removed` comments, no wrapper shims. Update all call sites. -- Whitespace and line endings matter for search_replace. Copy exactly from the read. +- Whitespace matters for `edit`. Copy `old_string` exactly from the read. **Prove it worked** @@ -93,7 +93,7 @@ You are **not** done when the edit landed, when there are no syntax errors, or w If you see any of these, the current approach is not working: - `lines_changed: 0` or a no-op result -- `diff_error`, "string not found", repeated search_replace failures +- `diff_error`, "string not found", repeated `edit` failures - The same error twice in a row - Three edits to the same file without the problem resolving - Whitespace/CRLF mismatch diff --git a/vibe/core/session/image_snapshot.py b/vibe/core/session/image_snapshot.py new file mode 100644 index 0000000..31adac4 --- /dev/null +++ b/vibe/core/session/image_snapshot.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import hashlib +import mimetypes +from pathlib import Path + +from vibe.core.types import IMAGE_EXTENSIONS, ImageAttachment + +_DEFAULT_MIME_BY_EXT = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", +} + + +class ImageSnapshotError(Exception): + pass + + +def snapshot_image( + source: Path, *, alias: str, session_dir: Path | None +) -> ImageAttachment: + source_abs = source.expanduser().resolve() + if not source_abs.is_file(): + raise ImageSnapshotError(f"Not a file: {source_abs}") + + ext = source_abs.suffix.lower() + if ext not in IMAGE_EXTENSIONS: + raise ImageSnapshotError(f"Unsupported image extension: {ext}") + + mime_type = _DEFAULT_MIME_BY_EXT.get(ext) or ( + mimetypes.guess_type(source_abs.name)[0] or "application/octet-stream" + ) + + try: + data = source_abs.read_bytes() + except OSError as e: + raise ImageSnapshotError(f"Failed to read image {source_abs}: {e}") from e + + # Session logging disabled: no snapshot copy is made; the attachment + # points to the original source. Resume is not possible in this mode so + # snapshot stability is not required. + if session_dir is None: + return ImageAttachment(path=source_abs, alias=alias, mime_type=mime_type) + + attachments_dir = session_dir / "attachments" + attachments_dir.mkdir(parents=True, exist_ok=True) + + digest = hashlib.sha1(data, usedforsecurity=False).hexdigest() + dest = attachments_dir / f"{digest}{ext}" + if not dest.exists(): + dest.write_bytes(data) + + return ImageAttachment(path=dest.resolve(), alias=alias, mime_type=mime_type) diff --git a/vibe/core/session/last_session_pointer.py b/vibe/core/session/last_session_pointer.py index abc4fd5..0b6b7c8 100644 --- a/vibe/core/session/last_session_pointer.py +++ b/vibe/core/session/last_session_pointer.py @@ -96,3 +96,37 @@ def load(config: SessionLoggingConfig) -> str | None: logger.debug("Failed to read last session pointer path=%s err=%s", path, e) return None return content or None + + +def clear_matching(config: SessionLoggingConfig, session_id: str) -> None: + if not session_id or not config.enabled: + return + + pointer_dir = _pointer_dir(config) + if not pointer_dir.is_dir(): + return + + try: + pointer_paths = list(pointer_dir.iterdir()) + except OSError as e: + logger.debug( + "Failed to list last session pointers path=%s err=%s", pointer_dir, e + ) + return + + for path in pointer_paths: + if not path.is_file(): + continue + try: + content = read_safe(path).text.strip() + except OSError as e: + logger.debug("Failed to read last session pointer path=%s err=%s", path, e) + continue + + if content != session_id: + continue + + try: + path.unlink() + except OSError as e: + logger.debug("Failed to clear last session pointer path=%s err=%s", path, e) diff --git a/vibe/core/session/saved_sessions.py b/vibe/core/session/saved_sessions.py index f4364f5..bd9ce22 100644 --- a/vibe/core/session/saved_sessions.py +++ b/vibe/core/session/saved_sessions.py @@ -1,10 +1,13 @@ from __future__ import annotations +import asyncio import json from pathlib import Path +import shutil from typing import Any from vibe.core.config import SessionLoggingConfig +from vibe.core.session import last_session_pointer from vibe.core.session.session_loader import METADATA_FILENAME, SessionLoader from vibe.core.session.session_logger import SessionLogger from vibe.core.utils.io import read_safe @@ -21,23 +24,37 @@ def _normalize_session_title(title: str) -> str: def _resolve_saved_session_dir( session_id: str, session_config: SessionLoggingConfig ) -> Path: + session_dir = _find_saved_session_dir(session_id, session_config) + if session_dir is None: + raise ValueError(f"Session not found: {session_id}") + + return session_dir + + +def _find_saved_session_dir( + session_id: str, session_config: SessionLoggingConfig +) -> Path | None: for session_dir in SessionLoader._find_session_dirs_by_short_id( session_id, session_config ): try: metadata = _load_raw_metadata(session_dir) - except (OSError, json.JSONDecodeError): + except (OSError, ValueError, json.JSONDecodeError): continue if metadata.get("session_id") == session_id: return session_dir - raise ValueError(f"Session not found: {session_id}") + return None def _load_raw_metadata(session_dir: Path) -> dict[str, Any]: metadata_path = session_dir / METADATA_FILENAME - return json.loads(read_safe(metadata_path).text) + metadata = json.loads(read_safe(metadata_path).text) + if not isinstance(metadata, dict): + raise ValueError(f"Session metadata must be an object: {metadata_path}") + + return metadata async def update_saved_session_title_at_path( @@ -56,3 +73,15 @@ async def update_saved_session_title( ) -> dict[str, Any]: session_dir = _resolve_saved_session_dir(session_id, session_config) return await update_saved_session_title_at_path(session_dir, title) + + +async def delete_saved_session( + session_id: str, session_config: SessionLoggingConfig +) -> None: + session_dir = _find_saved_session_dir(session_id, session_config) + if session_dir is None: + last_session_pointer.clear_matching(session_config, session_id) + return + + await asyncio.to_thread(shutil.rmtree, session_dir) + last_session_pointer.clear_matching(session_config, session_id) diff --git a/vibe/core/session/session_logger.py b/vibe/core/session/session_logger.py index 7ff73a7..dddafbb 100644 --- a/vibe/core/session/session_logger.py +++ b/vibe/core/session/session_logger.py @@ -302,7 +302,9 @@ class SessionLogger: if len(new_messages) == 0: return - messages_data = [m.model_dump(exclude_none=True) for m in new_messages] + messages_data = [ + m.model_dump(exclude_none=True, mode="json") for m in new_messages + ] await SessionLogger.persist_messages(messages_data, session_dir) # If message update succeeded, write metadata diff --git a/vibe/core/skills/__init__.py b/vibe/core/skills/__init__.py index 4a06713..c11dea8 100644 --- a/vibe/core/skills/__init__.py +++ b/vibe/core/skills/__init__.py @@ -1,7 +1,13 @@ from __future__ import annotations from vibe.core.skills.manager import SkillManager -from vibe.core.skills.models import SkillInfo, SkillMetadata +from vibe.core.skills.models import SkillConfigIssue, SkillInfo, SkillMetadata from vibe.core.skills.parser import SkillParseError -__all__ = ["SkillInfo", "SkillManager", "SkillMetadata", "SkillParseError"] +__all__ = [ + "SkillConfigIssue", + "SkillInfo", + "SkillManager", + "SkillMetadata", + "SkillParseError", +] diff --git a/vibe/core/skills/builtins/vibe.py b/vibe/core/skills/builtins/vibe.py index 1c42260..9fd74a8 100644 --- a/vibe/core/skills/builtins/vibe.py +++ b/vibe/core/skills/builtins/vibe.py @@ -126,6 +126,7 @@ input_price = 1.5 output_price = 7.5 thinking = "high" # "off", "low", "medium", "high", "max" auto_compact_threshold = 200000 +supports_images = true # vision-capable; allows @-mentioned images [[models]] name = "devstral-small-latest" @@ -147,7 +148,7 @@ alias = "local" tool_paths = ["/path/to/custom/tools"] # Enable only specific tools (glob and regex supported) -enabled_tools = ["bash", "read_file", "grep"] +enabled_tools = ["bash", "read", "grep"] # Disable specific tools disabled_tools = ["webfetch"] @@ -163,7 +164,7 @@ prompt for user permission before running the command. #### File Tool Permission Resolution -File-based tools (`read_file`, `grep`, `write_file`, `search_replace`) resolve +File-based tools (`read`, `grep`, `write_file`, `edit`) resolve permissions in this order (first match wins): 1. **Scratchpad** path → always allowed @@ -366,7 +367,7 @@ fix the problems. After 3 failed retries the hook stops retrying. ### Pattern Matching Tool, skill, and agent names support three matching modes: -- **Exact**: `"bash"`, `"read_file"` +- **Exact**: `"bash"`, `"read"` - **Glob**: `"bash*"`, `"mcp_*"` - **Regex**: `"re:^serena_.*$"` (full match, case-insensitive) @@ -409,7 +410,7 @@ There are two kinds of agents: ### Subagents -- **explore**: Read-only codebase exploration subagent (grep + read_file only). +- **explore**: Read-only codebase exploration subagent (grep + read only). Spawned by the model, not selectable by the user. Custom agents are TOML files in `~/.vibe/agents/NAME.toml`. @@ -448,6 +449,40 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`. - `/teleport` - Teleport session to Vibe Code Web (only available when Vibe Code is enabled) - `/exit` - Exit the application +## File Mentions (`@`) + +Type `@` in the chat input to autocomplete files and folders from the +project tree. Pressing Tab/Enter inserts the chosen path. Behavior +depends on the file kind: + +- **Text files** are read at submit and their contents are inlined into the + prompt text (up to ~256KB). +- **Folders** are inserted as a resource link header (name + uri). +- **Image files** (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`) become image + attachments — sent alongside the prompt as native multimodal content for + vision-capable models. + +Image attachments: + +- Require `supports_images = true` on the active model in `config.toml`. + By default this is enabled only on `mistral-vibe-cli-latest`. Sending + images to a non-vision model raises a clear error and the message is + not added to the conversation. +- Snapshotted into `/attachments/.` so that + resumed sessions stay reproducible even if the source file is moved. +- Capped at 10 MB per image and 8 images per message. +- Out-of-project paths work via `@/abs/path/to.png` (the picker only + suggests project files, but the `@`-parser accepts absolute paths). + Drag-and-drop from Finder into Terminal, iTerm2, or Ghostty is + intercepted at paste time: if the pasted content is a single bare + path to an image file (raw, `\\ `-escaped, or quoted), the input + automatically prepends `@` (and quotes paths containing spaces). + Non-image paths are pasted verbatim so non-image use cases are not + affected. +- Rendered in the chat bubble as a dim footer line linking each + attachment to its snapshot. Clicking opens the file with the OS + default image viewer. + ## Skills System Skills are specialized instruction sets the model can load on demand. @@ -460,7 +495,7 @@ Each skill is a directory containing a `SKILL.md` file with YAML frontmatter. name: my-skill description: What this skill does and when to use it. user-invocable: true -allowed-tools: bash read_file +allowed-tools: bash read --- # Skill Instructions @@ -511,9 +546,12 @@ directories. The trust database is stored in `~/.vibe/trusted_folders.toml`. Project-local config (`.vibe/` directory) is only loaded when the current directory is explicitly trusted. -Interactive mode prompts to trust unknown folders. Programmatic mode -(`-p`/`--prompt`) never prompts: the folder is untrusted. Use `--trust` to -trust cwd for the current invocation only (not persisted). +Interactive mode prompts to trust unknown folders. The prompt targets the +closest ancestor of the cwd (the cwd itself included) containing a `.git` +entry; the search excludes the user's home directory and the filesystem +root, and falls back to the cwd if no qualifying ancestor is found. +Programmatic mode (`-p`/`--prompt`) never prompts: the folder is untrusted. +Use `--trust` to trust cwd for the current invocation only (not persisted). ## Sensitive Files — DO NOT READ OR EDIT @@ -523,7 +561,7 @@ NEVER read, display, or edit any of these files: If the user asks to set or change an API key, instruct them to edit the `.env` file themselves. Do not offer to read it, write it, or display its contents. -Do not use tools (read_file, write_file, bash cat/echo, etc.) to access these files. +Do not use tools (read, write_file, bash cat/echo, etc.) to access these files. ## How to Modify Configuration @@ -535,7 +573,7 @@ To help the user modify their Vibe configuration: same directory (e.g. `cp ~/.vibe/config.toml ~/.vibe/config.toml.bak`). This applies to any config file you are about to modify (`config.toml`, `trusted_folders.toml`, agent TOML files, etc.) -3. **Edit the TOML file**: Make changes using the search_replace or write_file tool +3. **Edit the TOML file**: Make changes using the edit tool 4. **Reload**: The user can run `/reload` to apply changes without restarting For API keys, tell the user to edit `~/.vibe/.env` directly — never read or diff --git a/vibe/core/skills/manager.py b/vibe/core/skills/manager.py index dc4233b..d11b077 100644 --- a/vibe/core/skills/manager.py +++ b/vibe/core/skills/manager.py @@ -8,7 +8,12 @@ from typing import TYPE_CHECKING from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.logger import logger from vibe.core.skills.builtins import BUILTIN_SKILLS -from vibe.core.skills.models import ParsedSkillCommand, SkillInfo, SkillMetadata +from vibe.core.skills.models import ( + ParsedSkillCommand, + SkillConfigIssue, + SkillInfo, + SkillMetadata, +) from vibe.core.skills.parser import SkillParseError, parse_skill_markdown from vibe.core.utils import name_matches from vibe.core.utils.io import read_safe @@ -21,6 +26,7 @@ class SkillManager: def __init__(self, config_getter: Callable[[], VibeConfig]) -> None: self._config_getter = config_getter self._search_paths = self._compute_search_paths(self._config) + self._config_issues: list[SkillConfigIssue] = [] self.available_skills: Mapping[str, SkillInfo] = MappingProxyType( self._apply_filters(self._discover_skills()) ) @@ -36,6 +42,10 @@ class SkillManager: def _config(self) -> VibeConfig: return self._config_getter() + @property + def config_issues(self) -> tuple[SkillConfigIssue, ...]: + return tuple(self._config_issues) + def _apply_filters(self, skills: dict[str, SkillInfo]) -> dict[str, SkillInfo]: if self._config.enabled_skills: return { @@ -121,6 +131,9 @@ class SkillManager: skill_info = self._parse_skill_file(skill_file) except Exception as e: logger.warning("Failed to parse skill at %s: %s", skill_file, e) + self._config_issues.append( + SkillConfigIssue(file=skill_file, message=f"Failed to load: {e}") + ) return None return skill_info diff --git a/vibe/core/skills/models.py b/vibe/core/skills/models.py index 13d40d5..aa744a1 100644 --- a/vibe/core/skills/models.py +++ b/vibe/core/skills/models.py @@ -98,6 +98,11 @@ class SkillInfo(BaseModel): ) +class SkillConfigIssue(BaseModel): + file: Path + message: str + + class ParsedSkillCommand(BaseModel): name: str content: str diff --git a/vibe/core/telemetry/send.py b/vibe/core/telemetry/send.py index 11488b5..975b5eb 100644 --- a/vibe/core/telemetry/send.py +++ b/vibe/core/telemetry/send.py @@ -193,13 +193,9 @@ class TelemetryClient: nb_files_modified = 0 if status == "success" and result is not None: if tool_call.tool_name == "write_file": - file_existed = result.get("file_existed", False) - if file_existed: - nb_files_modified = 1 - else: - nb_files_created = 1 - elif tool_call.tool_name == "search_replace": - nb_files_modified = 1 if result.get("blocks_applied", 0) > 0 else 0 + nb_files_created = 1 + elif tool_call.tool_name == "edit": + nb_files_modified = 1 return nb_files_created, nb_files_modified def send_tool_call_finished( diff --git a/vibe/core/teleport/nuage.py b/vibe/core/teleport/nuage.py index fd287da..0676106 100644 --- a/vibe/core/teleport/nuage.py +++ b/vibe/core/teleport/nuage.py @@ -58,6 +58,13 @@ class NuageRequest(BaseModel): context: NuageContext +class TeleportSession(BaseModel): + model_config = ConfigDict(extra="forbid") + + metadata: dict[str, object] + messages: list[dict[str, object]] + + class NuageResponse(BaseModel): model_config = ConfigDict(extra="ignore") diff --git a/vibe/core/tools/builtins/edit.py b/vibe/core/tools/builtins/edit.py new file mode 100644 index 0000000..0c09cc3 --- /dev/null +++ b/vibe/core/tools/builtins/edit.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from collections.abc import AsyncGenerator +from pathlib import Path +from typing import ClassVar, final + +from pydantic import BaseModel, Field + +from vibe.core.rewind.manager import FileSnapshot +from vibe.core.scratchpad import is_scratchpad_path +from vibe.core.tools.base import ( + BaseTool, + BaseToolConfig, + BaseToolState, + InvokeContext, + ToolError, + ToolPermission, +) +from vibe.core.tools.permissions import PermissionContext +from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData +from vibe.core.tools.utils import resolve_file_tool_permission +from vibe.core.types import ToolResultEvent, ToolStreamEvent +from vibe.core.utils.io import ( + ReadSafeResult, + atomic_replace, + file_write_lock, + read_safe_async, +) + + +class EditArgs(BaseModel): + file_path: str = Field(description="The absolute path to the file to modify") + old_string: str = Field(description="The text to replace") + new_string: str = Field( + description="The text to replace it with (must be different from old_string)" + ) + replace_all: bool = Field( + default=False, + description="Replace all occurrences of old_string (default false)", + ) + + +class EditResult(BaseModel): + file: str + message: str + old_string: str + new_string: str + + +class EditConfig(BaseToolConfig): + permission: ToolPermission = ToolPermission.ASK + sensitive_patterns: list[str] = Field( + default=["**/.env", "**/.env.*"], + description="File patterns that trigger ASK even when permission is ALWAYS.", + ) + + +class Edit( + BaseTool[EditArgs, EditResult, EditConfig, BaseToolState], + ToolUIData[EditArgs, EditResult], +): + description: ClassVar[str] = ( + "Perform exact string replacements in files. " + "Supports single or bulk (replace_all) substitutions " + "with atomic, concurrent-safe writes." + ) + + def resolve_permission(self, args: EditArgs) -> PermissionContext | None: + return resolve_file_tool_permission( + args.file_path, + tool_name=self.get_name(), + allowlist=self.config.allowlist, + denylist=self.config.denylist, + config_permission=self.config.permission, + sensitive_patterns=self.config.sensitive_patterns, + ) + + def get_file_snapshot(self, args: EditArgs) -> FileSnapshot | None: + return self.get_file_snapshot_for_path(args.file_path) + + @classmethod + def format_call_display(cls, args: EditArgs) -> ToolCallDisplay: + tag = " (scratchpad)" if is_scratchpad_path(args.file_path) else "" + return ToolCallDisplay( + summary=f"Editing {Path(args.file_path).name}{tag}", + content=f"old_string: {args.old_string!r}\nnew_string: {args.new_string!r}", + ) + + @classmethod + def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: + if isinstance(event.result, EditResult): + tag = " (scratchpad)" if is_scratchpad_path(event.result.file) else "" + return ToolResultDisplay( + success=True, message=f"Edited {Path(event.result.file).name}{tag}" + ) + return ToolResultDisplay( + success=False, message=event.error or event.skip_reason or "No result" + ) + + @classmethod + def get_status_text(cls) -> str: + return "Editing files" + + async def _read_file(self, file_path: Path) -> ReadSafeResult: + return await read_safe_async(file_path, raise_on_error=True) + + async def _write_file( + self, file_path: Path, content: str, encoding: str, newline: str + ) -> None: + await atomic_replace(file_path, content, encoding=encoding, newline=newline) + + @final + async def run( + self, args: EditArgs, ctx: InvokeContext | None = None + ) -> AsyncGenerator[ToolStreamEvent | EditResult, None]: + file_path = self._validate_args(args) + + try: + async with file_write_lock(file_path): + result = await self._read_file(file_path) + original = result.text + + if args.old_string not in original: + raise ToolError( + f"String to replace not found in file.\n" + f"String: {args.old_string}" + ) + occurrences = original.count(args.old_string) + if occurrences > 1 and not args.replace_all: + raise ToolError( + f"Found {occurrences} matches of the string to replace, " + f"but replace_all is false. To replace all occurrences, " + f"set replace_all to true. To replace only one occurrence, " + f"please provide more context to uniquely identify the " + f"instance.\nString: {args.old_string}" + ) + + modified = self._apply_edit( + original, args.old_string, args.new_string, args.replace_all + ) + + if modified != original: + await self._write_file( + file_path, modified, result.encoding, result.newline + ) + except UnicodeDecodeError as e: + raise ToolError( + f"Cannot edit {file_path}: file is not valid text " + f"({e.encoding}, byte {e.start})" + ) from e + except PermissionError as e: + raise ToolError(f"Permission denied accessing file: {file_path}") from e + except OSError as e: + raise ToolError(f"OS error accessing {file_path}: {e}") from e + + if args.replace_all: + message = ( + "The file has been updated. All occurrences were successfully replaced" + ) + else: + message = "The file has been updated successfully." + + yield EditResult( + file=str(file_path), + message=message, + old_string=args.old_string, + new_string=args.new_string, + ) + + @final + def _validate_args(self, args: EditArgs) -> Path: + file_path_str = args.file_path.strip() + if not file_path_str: + raise ToolError("File path cannot be empty") + + if not args.old_string: + raise ToolError( + "old_string cannot be empty. Use write_file to create new files." + ) + + if args.old_string == args.new_string: + raise ToolError( + "No changes to make — old_string and new_string are identical" + ) + + file_path = Path(file_path_str).expanduser() + if not file_path.is_absolute(): + file_path = Path.cwd() / file_path + file_path = file_path.resolve() + + if not file_path.exists(): + raise ToolError(f"File does not exist: {file_path}") + + if not file_path.is_file(): + raise ToolError(f"Path is not a file: {file_path}") + + return file_path + + @staticmethod + def _apply_edit( + content: str, old_string: str, new_string: str, replace_all: bool + ) -> str: + if replace_all: + return content.replace(old_string, new_string) + return content.replace(old_string, new_string, 1) diff --git a/vibe/core/tools/builtins/prompts/bash.md b/vibe/core/tools/builtins/prompts/bash.md index df396aa..d22fa7f 100644 --- a/vibe/core/tools/builtins/prompts/bash.md +++ b/vibe/core/tools/builtins/prompts/bash.md @@ -11,13 +11,13 @@ Use the `bash` tool to run one-off shell commands. **IMPORTANT: Use dedicated tools if available instead of these bash commands:** **File Operations - DO NOT USE:** -- `cat filename` → Use `read_file(path="filename")` -- `head -n 20 filename` → Use `read_file(path="filename", limit=20)` -- `tail -n 20 filename` → Read with offset: `read_file(path="filename", offset=, limit=20)` -- `sed -n '100,200p' filename` → Use `read_file(path="filename", offset=99, limit=101)` -- `less`, `more`, `vim`, `nano` → Use `read_file` with offset/limit for navigation +- `cat filename` → Use `read(file_path="filename")` +- `head -n 20 filename` → Use `read(file_path="filename", limit=20)` +- `tail -n 20 filename` → Read with offset: `read(file_path="filename", offset=, limit=20)` +- `sed -n '100,200p' filename` → Use `read(file_path="filename", offset=100, limit=101)` +- `less`, `more`, `vim`, `nano` → Use `read` with offset/limit for navigation - `echo "content" > file` → Use `write_file(path="file", content="content")` -- `echo "content" >> file` → Read first, then `write_file` with overwrite=true +- `echo "content" >> existing_file` → Read first, then use `search_replace` to append (write_file refuses to overwrite) **Search Operations - DO NOT USE:** - `grep -r "pattern" .` → Use `grep(pattern="pattern", path=".")` @@ -26,9 +26,9 @@ Use the `bash` tool to run one-off shell commands. - `locate` → Use `grep` tool **File Modification - DO NOT USE:** -- `sed -i 's/old/new/g' file` → Use `search_replace` tool -- `awk` for file editing → Use `search_replace` tool -- Any in-place file editing → Use `search_replace` tool +- `sed -i 's/old/new/g' file` → Use `edit` tool +- `awk` for file editing → Use `edit` tool +- Any in-place file editing → Use `edit` tool **APPROPRIATE bash uses:** - System information: `pwd`, `whoami`, `date`, `uname -a` @@ -51,9 +51,9 @@ bash("head -1000 large_file.txt") # Inefficient RIGHT: ```python # First chunk -read_file(path="large_file.txt", limit=1000) -# If was_truncated=true, read next chunk -read_file(path="large_file.txt", offset=1000, limit=1000) +read(file_path="large_file.txt", limit=1000) +# If output is truncated, read next chunk +read(file_path="large_file.txt", offset=1001, limit=1000) ``` **Example: Searching for patterns** diff --git a/vibe/core/tools/builtins/prompts/edit.md b/vibe/core/tools/builtins/prompts/edit.md new file mode 100644 index 0000000..9aec531 --- /dev/null +++ b/vibe/core/tools/builtins/prompts/edit.md @@ -0,0 +1,19 @@ +Use `edit` to make exact string replacements in files. + +**Arguments:** +- `file_path`: The path to the file to modify +- `old_string`: The exact text to find and replace +- `new_string`: The replacement text (must differ from old_string) +- `replace_all`: Set to `true` to replace all occurrences (default: `false`) + +**IMPORTANT:** + +- **ALWAYS** call `read` on the target before `edit`. The on-disk content may have changed since you last saw it (user edits, prior tool calls, external processes). Operating on stale content will either fail the exact-match check or silently apply the edit to the wrong place. +- The `old_string` must match the file content exactly, including whitespace and indentation +- When editing text from `read` output, match only the content AFTER the line number prefix (the ` 1→` part is not in the file). Never include any part of the line number prefix in old_string or new_string. +- If `old_string` appears multiple times, the edit will fail unless `replace_all` is `true`. Either provide more surrounding context to uniquely identify the target, or set `replace_all` to `true`. +- Use `replace_all` for renaming variables or strings across the file +- Prefer editing existing files over writing new ones +- `old_string` cannot be empty; use `write_file` to create new files +- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. +- If an `edit` fails because the `old_string` was not found, re-read the file before retrying — do not guess at variations. diff --git a/vibe/core/tools/builtins/prompts/read.md b/vibe/core/tools/builtins/prompts/read.md new file mode 100644 index 0000000..cd73819 --- /dev/null +++ b/vibe/core/tools/builtins/prompts/read.md @@ -0,0 +1,19 @@ +Use `read` to read the content of a file with line numbers. It handles encoding safely. + +- By default, it reads up to 2000 lines from the beginning of the file +- Output larger than 50KB returns an error; use `offset` and `limit` for larger files +- Results include line numbers in ` 1→content` format (1-indexed) +- Use `offset` (1-indexed line number) and `limit` to read specific portions +- This is more efficient than using `bash` with `cat` or `wc` + +**Strategy for large files:** + +1. Call `read` without offset/limit to get the start of the file +2. If the output is too large, use `offset` and `limit` to read targeted sections +3. Prefer `grep` to find specific content rather than reading sequentially chunk by chunk +4. Do not call `read` more than 3 times on the same file without responding to the user first + +**Do not read:** +- Model checkpoint directories or weight files (.bin, .safetensors, .pt, .gguf, optimizer states, etc.) +- Binary files of any kind +- Entire directory trees of training runs or large codebases. If the user provides paths to such files, treat them as references. Do not open them unless the user explicitly asks you to inspect a specific file. diff --git a/vibe/core/tools/builtins/prompts/read_file.md b/vibe/core/tools/builtins/prompts/read_file.md deleted file mode 100644 index e1ffc1f..0000000 --- a/vibe/core/tools/builtins/prompts/read_file.md +++ /dev/null @@ -1,18 +0,0 @@ -Use `read_file` to read the content of a file. It's designed to handle large files safely. - -- By default, it reads from the beginning of the file. -- Use `offset` (line number) and `limit` (number of lines) to read specific parts or chunks of a file. This is efficient for exploring large files. -- The result includes `was_truncated: true` if the file content was cut short due to size limits. -- This is more efficient than using `bash` with `cat` or `wc`. - -**Strategy for large files:** - -1. Call `read_file` with a `limit` (e.g., 1000 lines) to get the start of the file. -2. If `was_truncated` is true, the file is large. STOP and assess: do you already have enough information to answer the user's question? If yes, respond immediately — do not keep reading. -3. If you need more, prefer targeted reads (e.g., jump to a specific offset, read the last 100 lines, search for a relevant section) over reading sequentially chunk by chunk. -4. Do not call `read_file` more than 3 times on the same file without responding to the user first. - -**Do not read or explore:** -- Model checkpoint directories or weight files (.bin, .safetensors, .pt, .gguf, optimizer states, etc.) -- Binary files of any kind -- Entire directory trees of training runs or large codebases. If the user provides paths to such files, treat them as references. Do not open them unless the user explicitly asks you to inspect a specific file. diff --git a/vibe/core/tools/builtins/prompts/search_replace.md b/vibe/core/tools/builtins/prompts/search_replace.md deleted file mode 100644 index 9ce056a..0000000 --- a/vibe/core/tools/builtins/prompts/search_replace.md +++ /dev/null @@ -1,43 +0,0 @@ -Use `search_replace` to make targeted changes to files using SEARCH/REPLACE blocks. This tool finds exact text matches and replaces them. - -Arguments: -- `file_path`: The path to the file to modify -- `content`: The SEARCH/REPLACE blocks defining the changes - -The content format is: - -``` -<<<<<<< SEARCH -[exact text to find in the file] -======= -[exact text to replace it with] ->>>>>>> REPLACE -``` - -You can include multiple SEARCH/REPLACE blocks to make multiple changes to the same file: - -``` -<<<<<<< SEARCH -def old_function(): - return "old value" -======= -def new_function(): - return "new value" ->>>>>>> REPLACE - -<<<<<<< SEARCH -import os -======= -import os -import sys ->>>>>>> REPLACE -``` - -IMPORTANT: - -- The SEARCH text must match EXACTLY (including whitespace, indentation, and line endings) -- The SEARCH text must appear exactly once in the file - if it appears multiple times, the tool will error -- Use at least 5 equals signs (=====) between SEARCH and REPLACE sections -- The tool will provide detailed error messages showing context if search text is not found -- Each search/replace block is applied in order, so later blocks see the results of earlier ones -- Be careful with escape sequences in string literals - use \n not \\n for newlines in code diff --git a/vibe/core/tools/builtins/prompts/write_file.md b/vibe/core/tools/builtins/prompts/write_file.md index b3859b5..f4b8777 100644 --- a/vibe/core/tools/builtins/prompts/write_file.md +++ b/vibe/core/tools/builtins/prompts/write_file.md @@ -1,42 +1,27 @@ -Use `write_file` to write content to a file. +Use `write_file` to create a new file. **Arguments:** - `path`: The file path (relative or absolute) - `content`: The content to write to the file -- `overwrite`: Must be set to `true` to overwrite an existing file (default: `false`) -**IMPORTANT SAFETY RULES:** +**BEHAVIOR:** -- By default, the tool will **fail if the file already exists** to prevent accidental data loss -- To **overwrite** an existing file, you **MUST** set `overwrite: true` -- To **create a new file**, just provide the `path` and `content` (overwrite defaults to false) -- If parent directories don't exist, they will be created automatically +- `write_file` can ONLY create new files. +- If the file already exists, the tool returns an error. Use `search_replace` to edit existing files. +- Parent directories are created automatically if they don't exist. **BEST PRACTICES:** -- **ALWAYS** use the `read_file` tool first before overwriting an existing file to understand its current contents -- **ALWAYS** prefer using `search_replace` to edit existing files rather than overwriting them completely -- **NEVER** write new files unless explicitly required - prefer modifying existing files -- **NEVER** proactively create documentation files (*.md) or README files unless explicitly requested -- **AVOID** using emojis in file content unless the user explicitly requests them +- **NEVER** use `write_file` to modify an existing file — it will fail. Use `edit` instead. +- **NEVER** write new files unless explicitly required — prefer modifying existing files via `edit`. +- **NEVER** proactively create documentation files (*.md) or README files unless explicitly requested. +- **AVOID** using emojis in file content unless the user explicitly requests them. -**Usage Examples:** +**Usage Example:** ```python -# Create a new file (will error if file exists) write_file( path="src/new_module.py", content="def hello():\n return 'Hello World'" ) - -# Overwrite an existing file (must read it first!) -# First: read_file(path="src/existing.py") -# Then: -write_file( - path="src/existing.py", - content="# Updated content\ndef new_function():\n pass", - overwrite=True -) ``` - -**Remember:** For editing existing files, prefer `search_replace` over `write_file` to preserve unchanged portions and avoid accidental data loss. diff --git a/vibe/core/tools/builtins/read.py b/vibe/core/tools/builtins/read.py new file mode 100644 index 0000000..5d4f796 --- /dev/null +++ b/vibe/core/tools/builtins/read.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +from collections.abc import AsyncGenerator +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar, final + +from humanize import naturalsize +from pydantic import BaseModel, Field + +from vibe.core.config.harness_files import get_harness_files_manager +from vibe.core.scratchpad import is_scratchpad_path +from vibe.core.tools.base import ( + BaseTool, + BaseToolConfig, + BaseToolState, + InvokeContext, + ToolError, + ToolPermission, +) +from vibe.core.tools.permissions import PermissionContext +from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData +from vibe.core.tools.utils import resolve_file_tool_permission +from vibe.core.types import ToolStreamEvent +from vibe.core.utils import VIBE_WARNING_TAG +from vibe.core.utils.io import read_lines_safe_async + +if TYPE_CHECKING: + from vibe.core.types import ToolResultEvent + +_KB = 1024 +DEFAULT_LINE_LIMIT = 2000 +MAX_BYTES = 50 * _KB + + +def _add_line_numbers(lines: list[str], *, start: int) -> str: + return "\n".join( + f"{str(n).rjust(9)}\u2192{line}" for n, line in enumerate(lines, start=start) + ) + + +def _warning(message: str) -> str: + return f"<{VIBE_WARNING_TAG}>{message}" + + +class ReadArgs(BaseModel): + file_path: str = Field(description="The absolute path to the file to read.") + offset: int | None = Field( + default=None, + ge=1, + description=( + "The line number to start reading from (1-indexed). " + "Only provide if the file is too large to read at once." + ), + ) + limit: int = Field( + default=DEFAULT_LINE_LIMIT, + gt=0, + description=( + "The number of lines to read. Lower it to read a smaller portion " + "of a large file." + ), + ) + + +class ReadResult(BaseModel): + file_path: str + content: str + num_lines: int + start_line: int + total_lines: int | None = None + was_truncated: bool = False + + +class ReadConfig(BaseToolConfig): + permission: ToolPermission = ToolPermission.ALWAYS + sensitive_patterns: list[str] = Field( + default=["**/.env", "**/.env.*"], + description="File patterns that trigger ASK even when permission is ALWAYS.", + ) + max_read_bytes: int = Field( + default=MAX_BYTES, + gt=0, + description="Maximum selected/output bytes to return in one call.", + ) + + +class ReadState(BaseToolState): + injected_agents_md: set[str] = Field(default_factory=set) + + +class Read( + BaseTool[ReadArgs, ReadResult, ReadConfig, ReadState], + ToolUIData[ReadArgs, ReadResult], +): + description: ClassVar[str] = ( + "Read a text file with line numbers. " + "Results are formatted with line number prefixes for easy reference." + ) + + def resolve_permission(self, args: ReadArgs) -> PermissionContext | None: + return resolve_file_tool_permission( + args.file_path, + tool_name=self.get_name(), + allowlist=self.config.allowlist, + denylist=self.config.denylist, + config_permission=self.config.permission, + sensitive_patterns=self.config.sensitive_patterns, + ) + + def get_result_extra(self, result: ReadResult) -> str | None: + try: + mgr = get_harness_files_manager() + except RuntimeError: + return None + docs = mgr.find_subdirectory_agents_md(Path(result.file_path)) + new_docs = [ + (d, c) + for d, c in docs + if str(d.resolve()) not in self.state.injected_agents_md + ] + if not new_docs: + return None + for d, _ in new_docs: + self.state.injected_agents_md.add(str(d.resolve())) + sections = [ + f"Contents of {d}/AGENTS.md (project instructions for this directory):\n\n{c.strip()}" + for d, c in new_docs + ] + return f"<{VIBE_WARNING_TAG}>\n{'\n\n'.join(sections)}\n" + + async def _read_file( + self, args: ReadArgs, file_path: Path + ) -> tuple[list[str], int | None, bool]: + start_line = args.offset or 1 + try: + result = await read_lines_safe_async( + file_path, + start_line=start_line, + limit=args.limit, + max_bytes=self.config.max_read_bytes, + ) + except OSError as exc: + raise ToolError(f"Error reading {file_path}: {exc}") from exc + return result.lines, result.total_lines, result.was_truncated + + @final + async def run( + self, args: ReadArgs, ctx: InvokeContext | None = None + ) -> AsyncGenerator[ToolStreamEvent | ReadResult, None]: + file_path = self._resolve_path(args.file_path) + + start_line = args.offset or 1 + + selected, total_lines, was_truncated = await self._read_file(args, file_path) + + if selected: + content = _add_line_numbers(selected, start=start_line) + elif total_lines == 0: + content = _warning("Warning: the file exists but the contents are empty.") + elif total_lines is None: + content = _warning(f"Warning: no content returned for offset {start_line}.") + else: + content = _warning( + f"Warning: the file exists but is shorter than the provided " + f"offset ({start_line}). The file has {total_lines} lines." + ) + + size = len(content.encode("utf-8")) + if size > self.config.max_read_bytes: + raise ToolError( + f"Output ({naturalsize(size, binary=True)}) exceeds maximum " + f"allowed size ({naturalsize(self.config.max_read_bytes, binary=True)}). " + f"Use offset and limit to read a smaller portion of the file." + ) + + yield ReadResult( + file_path=str(file_path), + content=content, + num_lines=len(selected), + start_line=start_line, + total_lines=total_lines, + was_truncated=was_truncated, + ) + + def _resolve_path(self, raw_path: str) -> Path: + if not raw_path.strip(): + raise ToolError("file_path cannot be empty") + + path = Path(raw_path).expanduser() + if not path.is_absolute(): + path = Path.cwd() / path + path = path.resolve() + + if not path.exists(): + raise ToolError(f"File not found at: {path}") + if path.is_dir(): + raise ToolError(f"Path is a directory, not a file: {path}") + return path + + @classmethod + def format_call_display(cls, args: ReadArgs) -> ToolCallDisplay: + tag = " (scratchpad)" if is_scratchpad_path(args.file_path) else "" + summary = f"Reading {args.file_path}" + extras: list[str] = [] + if args.offset: + extras.append(f"from line {args.offset}") + if args.limit != DEFAULT_LINE_LIMIT: + extras.append(f"limit {args.limit} lines") + if extras: + summary += f" ({', '.join(extras)})" + return ToolCallDisplay(summary=f"{summary}{tag}") + + @classmethod + def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: + if not isinstance(event.result, ReadResult): + return ToolResultDisplay( + success=False, message=event.error or event.skip_reason or "No result" + ) + + path_obj = Path(event.result.file_path) + tag = " (scratchpad)" if is_scratchpad_path(event.result.file_path) else "" + word = "line" if event.result.num_lines == 1 else "lines" + message = f"Read {event.result.num_lines} {word} from {path_obj.name}{tag}" + if event.result.was_truncated or ( + event.result.total_lines is not None + and event.result.start_line + event.result.num_lines - 1 + < event.result.total_lines + ): + message += " (truncated)" + + return ToolResultDisplay(success=True, message=message) + + @classmethod + def get_status_text(cls) -> str: + return "Reading file" diff --git a/vibe/core/tools/builtins/read_file.py b/vibe/core/tools/builtins/read_file.py deleted file mode 100644 index 0068eb5..0000000 --- a/vibe/core/tools/builtins/read_file.py +++ /dev/null @@ -1,234 +0,0 @@ -from __future__ import annotations - -from collections.abc import AsyncGenerator -from pathlib import Path -from typing import TYPE_CHECKING, ClassVar, NamedTuple, final - -import anyio -from pydantic import BaseModel, Field - -from vibe.core.config.harness_files import get_harness_files_manager -from vibe.core.scratchpad import is_scratchpad_path -from vibe.core.tools.base import ( - BaseTool, - BaseToolConfig, - BaseToolState, - InvokeContext, - ToolError, - ToolPermission, -) -from vibe.core.tools.permissions import PermissionContext -from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData -from vibe.core.tools.utils import resolve_file_tool_permission -from vibe.core.types import ToolStreamEvent -from vibe.core.utils import VIBE_WARNING_TAG -from vibe.core.utils.io import decode_safe - -if TYPE_CHECKING: - from vibe.core.types import ToolResultEvent - - -class _ReadResult(NamedTuple): - lines: list[str] - bytes_read: int - was_truncated: bool - - -class ReadFileArgs(BaseModel): - path: str - offset: int = Field( - default=0, - description="Line number to start reading from (0-indexed, inclusive).", - ) - limit: int | None = Field( - default=None, description="Maximum number of lines to read." - ) - - -class ReadFileResult(BaseModel): - path: str - content: str - offset: int = 0 - lines_read: int - was_truncated: bool = Field( - description="True if the reading was stopped due to the max_read_bytes limit." - ) - - -class ReadFileToolConfig(BaseToolConfig): - permission: ToolPermission = ToolPermission.ALWAYS - sensitive_patterns: list[str] = Field( - default=["**/.env", "**/.env.*"], - description="File patterns that trigger ASK even when permission is ALWAYS.", - ) - - max_read_bytes: int = Field( - default=64_000, description="Maximum total bytes to read from a file in one go." - ) - - -class ReadFileState(BaseToolState): - injected_agents_md: set[str] = Field(default_factory=set) - - -class ReadFile( - BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, ReadFileState], - ToolUIData[ReadFileArgs, ReadFileResult], -): - description: ClassVar[str] = ( - "Read a text file (encoding detected safely), returning content from a " - "specific line range. Reading is capped by a byte limit for safety." - ) - - @final - async def run( - self, args: ReadFileArgs, ctx: InvokeContext | None = None - ) -> AsyncGenerator[ToolStreamEvent | ReadFileResult, None]: - file_path = self._prepare_and_validate_path(args) - - read_result = await self._read_file(args, file_path) - - yield ReadFileResult( - path=str(file_path), - content="".join(read_result.lines), - offset=args.offset, - lines_read=len(read_result.lines), - was_truncated=read_result.was_truncated, - ) - - def resolve_permission(self, args: ReadFileArgs) -> PermissionContext | None: - return resolve_file_tool_permission( - args.path, - tool_name=self.get_name(), - allowlist=self.config.allowlist, - denylist=self.config.denylist, - config_permission=self.config.permission, - sensitive_patterns=self.config.sensitive_patterns, - ) - - def get_result_extra(self, result: ReadFileResult) -> str | None: - try: - mgr = get_harness_files_manager() - except RuntimeError: - return None - docs = mgr.find_subdirectory_agents_md(Path(result.path)) - new_docs = [ - (d, c) - for d, c in docs - if str(d.resolve()) not in self.state.injected_agents_md - ] - if not new_docs: - return None - for d, _ in new_docs: - self.state.injected_agents_md.add(str(d.resolve())) - sections = [ - f"Contents of {d}/AGENTS.md (project instructions for this directory):\n\n{c.strip()}" - for d, c in new_docs - ] - return f"<{VIBE_WARNING_TAG}>\n{'\n\n'.join(sections)}\n" - - def _prepare_and_validate_path(self, args: ReadFileArgs) -> Path: - self._validate_inputs(args) - - file_path = Path(args.path).expanduser() - if not file_path.is_absolute(): - file_path = Path.cwd() / file_path - - self._validate_path(file_path) - return file_path - - async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult: - try: - raw_lines: list[bytes] = [] - bytes_read = 0 - was_truncated = True - - async with await anyio.Path(file_path).open("rb") as f: - line_index = 0 - while raw_line := await f.readline(): - if line_index < args.offset: - line_index += 1 - continue - - if args.limit is not None and len(raw_lines) >= args.limit: - break - - line_bytes = len(raw_line) - if bytes_read + line_bytes > self.config.max_read_bytes: - break - - raw_lines.append(raw_line) - bytes_read += line_bytes - line_index += 1 - else: - was_truncated = False - except OSError as exc: - raise ToolError(f"Error reading {file_path}: {exc}") from exc - - lines_to_return = decode_safe(b"".join(raw_lines)).text.splitlines( - keepends=True - ) - return _ReadResult( - lines=lines_to_return, bytes_read=bytes_read, was_truncated=was_truncated - ) - - def _validate_inputs(self, args: ReadFileArgs) -> None: - if not args.path.strip(): - raise ToolError("Path cannot be empty") - if args.offset < 0: - raise ToolError("Offset cannot be negative") - if args.limit is not None and args.limit <= 0: - raise ToolError("Limit, if provided, must be a positive number") - - def _validate_path(self, file_path: Path) -> None: - try: - resolved_path = file_path.resolve() - except ValueError: - raise ToolError( - f"Security error: Cannot read path '{file_path}' outside of the project directory '{Path.cwd()}'." - ) - except FileNotFoundError: - raise ToolError(f"File not found at: {file_path}") - - if not resolved_path.exists(): - raise ToolError(f"File not found at: {file_path}") - if resolved_path.is_dir(): - raise ToolError(f"Path is a directory, not a file: {file_path}") - - @classmethod - def format_call_display(cls, args: ReadFileArgs) -> ToolCallDisplay: - tag = " (scratchpad)" if is_scratchpad_path(args.path) else "" - summary = f"Reading {args.path}" - if args.offset > 0 or args.limit is not None: - parts = [] - if args.offset > 0: - parts.append(f"from line {args.offset}") - if args.limit is not None: - parts.append(f"limit {args.limit} lines") - summary += f" ({', '.join(parts)})" - return ToolCallDisplay(summary=f"{summary}{tag}") - - @classmethod - def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: - if not isinstance(event.result, ReadFileResult): - return ToolResultDisplay( - success=False, message=event.error or event.skip_reason or "No result" - ) - - path_obj = Path(event.result.path) - tag = " (scratchpad)" if is_scratchpad_path(event.result.path) else "" - message = f"Read {event.result.lines_read} line{'' if event.result.lines_read <= 1 else 's'} from {path_obj.name}{tag}" - if event.result.was_truncated: - message += " (truncated)" - - return ToolResultDisplay( - success=True, - message=message, - warnings=["File was truncated due to size limit"] - if event.result.was_truncated - else [], - ) - - @classmethod - def get_status_text(cls) -> str: - return "Reading file" diff --git a/vibe/core/tools/builtins/search_replace.py b/vibe/core/tools/builtins/search_replace.py deleted file mode 100644 index bd1322d..0000000 --- a/vibe/core/tools/builtins/search_replace.py +++ /dev/null @@ -1,480 +0,0 @@ -from __future__ import annotations - -from collections.abc import AsyncGenerator -import difflib -from pathlib import Path -import re -import shutil -from typing import ClassVar, NamedTuple, final - -import anyio -from pydantic import BaseModel, Field - -from vibe.core.rewind.manager import FileSnapshot -from vibe.core.scratchpad import is_scratchpad_path -from vibe.core.tools.base import ( - BaseTool, - BaseToolConfig, - BaseToolState, - InvokeContext, - ToolError, -) -from vibe.core.tools.permissions import PermissionContext -from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData -from vibe.core.tools.utils import resolve_file_tool_permission -from vibe.core.types import ToolResultEvent, ToolStreamEvent -from vibe.core.utils.io import ReadSafeResult, read_safe_async - -SEARCH_REPLACE_BLOCK_RE = re.compile( - r"<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE", flags=re.DOTALL -) - -SEARCH_REPLACE_BLOCK_WITH_FENCE_RE = re.compile( - r"```[\s\S]*?\n<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE\s*\n```", - flags=re.DOTALL, -) - - -class SearchReplaceBlock(NamedTuple): - search: str - replace: str - - -class FuzzyMatch(NamedTuple): - similarity: float - start_line: int - end_line: int - text: str - - -class BlockApplyResult(NamedTuple): - content: str - applied: int - errors: list[str] - warnings: list[str] - - -class SearchReplaceArgs(BaseModel): - file_path: str - content: str - - -class SearchReplaceResult(BaseModel): - file: str - blocks_applied: int - lines_changed: int - content: str - warnings: list[str] = Field(default_factory=list) - - -class SearchReplaceConfig(BaseToolConfig): - sensitive_patterns: list[str] = Field( - default=["**/.env", "**/.env.*"], - description="File patterns that trigger ASK even when permission is ALWAYS.", - ) - max_content_size: int = 100_000 - create_backup: bool = False - fuzzy_threshold: float = 0.9 - - -class SearchReplace( - BaseTool[ - SearchReplaceArgs, SearchReplaceResult, SearchReplaceConfig, BaseToolState - ], - ToolUIData[SearchReplaceArgs, SearchReplaceResult], -): - description: ClassVar[str] = ( - "Replace sections of files using SEARCH/REPLACE blocks. " - "Supports fuzzy matching and detailed error reporting. " - "Format: <<<<<<< SEARCH\\n[text]\\n=======\\n[replacement]\\n>>>>>>> REPLACE" - ) - - @classmethod - def format_call_display(cls, args: SearchReplaceArgs) -> ToolCallDisplay: - tag = " (scratchpad)" if is_scratchpad_path(args.file_path) else "" - blocks = cls._parse_search_replace_blocks(args.content) - return ToolCallDisplay( - summary=f"Patching {args.file_path} ({len(blocks)} blocks){tag}", - content=args.content, - ) - - @classmethod - def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: - if isinstance(event.result, SearchReplaceResult): - path_name = Path(event.result.file).name - tag = " (scratchpad)" if is_scratchpad_path(event.result.file) else "" - return ToolResultDisplay( - success=True, - message=f"Applied {event.result.blocks_applied} block{'' if event.result.blocks_applied == 1 else 's'} to {path_name}{tag}", - warnings=event.result.warnings, - ) - - return ToolResultDisplay(success=True, message="Patch applied") - - @classmethod - def get_status_text(cls) -> str: - return "Editing files" - - def get_file_snapshot(self, args: SearchReplaceArgs) -> FileSnapshot | None: - return self.get_file_snapshot_for_path(args.file_path) - - def resolve_permission(self, args: SearchReplaceArgs) -> PermissionContext | None: - return resolve_file_tool_permission( - args.file_path, - tool_name=self.get_name(), - allowlist=self.config.allowlist, - denylist=self.config.denylist, - config_permission=self.config.permission, - sensitive_patterns=self.config.sensitive_patterns, - ) - - @final - async def run( - self, args: SearchReplaceArgs, ctx: InvokeContext | None = None - ) -> AsyncGenerator[ToolStreamEvent | SearchReplaceResult, None]: - file_path, search_replace_blocks = self._prepare_and_validate_args(args) - - decoded = await self._read_file(file_path) - original_content = decoded.text - - block_result = self._apply_blocks( - original_content, - search_replace_blocks, - file_path, - self.config.fuzzy_threshold, - ) - - if block_result.errors: - error_message = "SEARCH/REPLACE blocks failed:\n" + "\n\n".join( - block_result.errors - ) - if block_result.warnings: - error_message += "\n\nWarnings encountered:\n" + "\n".join( - block_result.warnings - ) - raise ToolError(error_message) - - modified_content = block_result.content - - # Calculate line changes - if modified_content == original_content: - lines_changed = 0 - else: - original_lines = len(original_content.splitlines()) - new_lines = len(modified_content.splitlines()) - lines_changed = new_lines - original_lines - - try: - if self.config.create_backup: - await self._backup_file(file_path) - except Exception: - pass - - await self._write_file( - file_path, modified_content, decoded.encoding, decoded.newline - ) - - yield SearchReplaceResult( - file=str(file_path), - blocks_applied=block_result.applied, - lines_changed=lines_changed, - warnings=block_result.warnings, - content=args.content, - ) - - @final - def _prepare_and_validate_args( - self, args: SearchReplaceArgs - ) -> tuple[Path, list[SearchReplaceBlock]]: - file_path_str = args.file_path.strip() - content = args.content.strip() - - if not file_path_str: - raise ToolError("File path cannot be empty") - - if len(content) > self.config.max_content_size: - raise ToolError( - f"Content size ({len(content)} bytes) exceeds max_content_size " - f"({self.config.max_content_size} bytes)" - ) - - if not content: - raise ToolError("Empty content provided") - - project_root = Path.cwd() - file_path = Path(file_path_str).expanduser() - if not file_path.is_absolute(): - file_path = project_root / file_path - file_path = file_path.resolve() - - if not file_path.exists(): - raise ToolError(f"File does not exist: {file_path}") - - if not file_path.is_file(): - raise ToolError(f"Path is not a file: {file_path}") - - search_replace_blocks = self._parse_search_replace_blocks(content) - if not search_replace_blocks: - raise ToolError( - "No valid SEARCH/REPLACE blocks found in content.\n" - "Expected format:\n" - "<<<<<<< SEARCH\n" - "[exact content to find]\n" - "=======\n" - "[new content to replace with]\n" - ">>>>>>> REPLACE" - ) - - return file_path, search_replace_blocks - - async def _read_file(self, file_path: Path) -> ReadSafeResult: - try: - return await read_safe_async(file_path, raise_on_error=True) - except PermissionError: - raise ToolError(f"Permission denied reading file: {file_path}") - except OSError as e: - raise ToolError(f"OS error reading {file_path}: {e}") from e - except Exception as e: - raise ToolError(f"Unexpected error reading {file_path}: {e}") from e - - async def _backup_file(self, file_path: Path) -> None: - shutil.copy2(file_path, file_path.with_suffix(file_path.suffix + ".bak")) - - async def _write_file( - self, file_path: Path, content: str, encoding: str, newline: str - ) -> None: - try: - async with await anyio.Path(file_path).open( - mode="w", encoding=encoding, newline=newline - ) as f: - await f.write(content) - except UnicodeEncodeError as e: - raise ToolError( - f"Cannot encode patched content for {file_path} using {encoding!r}: {e}" - ) from e - except PermissionError: - raise ToolError(f"Permission denied writing to file: {file_path}") - except OSError as e: - raise ToolError(f"OS error writing to {file_path}: {e}") from e - except Exception as e: - raise ToolError(f"Unexpected error writing to {file_path}: {e}") from e - - @final - @staticmethod - def _apply_blocks( - content: str, - blocks: list[SearchReplaceBlock], - filepath: Path, - fuzzy_threshold: float = 0.9, - ) -> BlockApplyResult: - applied = 0 - errors: list[str] = [] - warnings: list[str] = [] - current_content = content - - for i, (search, replace) in enumerate(blocks, 1): - if search not in current_content: - context = SearchReplace._find_search_context(current_content, search) - fuzzy_context = SearchReplace._find_fuzzy_match_context( - current_content, search, fuzzy_threshold - ) - - error_msg = ( - f"SEARCH/REPLACE block {i} failed: Search text not found in {filepath}\n" - f"Search text was:\n{search!r}\n" - f"Context analysis:\n{context}" - ) - - if fuzzy_context: - error_msg += f"\n{fuzzy_context}" - - error_msg += ( - "\nDebugging tips:\n" - "1. Check for exact whitespace/indentation match\n" - "2. Verify line endings match the file exactly (\\r\\n vs \\n)\n" - "3. Ensure the search text hasn't been modified by previous blocks or user edits\n" - "4. Check for typos or case sensitivity issues" - ) - - errors.append(error_msg) - continue - - occurrences = current_content.count(search) - if occurrences > 1: - warning_msg = ( - f"Search text in block {i} appears {occurrences} times in the file. " - f"Only the first occurrence will be replaced. Consider making your " - f"search pattern more specific to avoid unintended changes." - ) - warnings.append(warning_msg) - - current_content = current_content.replace(search, replace, 1) - applied += 1 - - return BlockApplyResult( - content=current_content, applied=applied, errors=errors, warnings=warnings - ) - - @final - @staticmethod - def _find_fuzzy_match_context( - content: str, search_text: str, threshold: float = 0.9 - ) -> str | None: - best_match = SearchReplace._find_best_fuzzy_match( - content, search_text, threshold - ) - - if not best_match: - return None - - diff = SearchReplace._create_unified_diff( - search_text, best_match.text, "SEARCH", "CLOSEST MATCH" - ) - - similarity_pct = best_match.similarity * 100 - - return ( - f"Closest fuzzy match (similarity {similarity_pct:.1f}%) " - f"at lines {best_match.start_line}–{best_match.end_line}:\n" - f"```diff\n{diff}\n```" - ) - - @final - @staticmethod - def _find_best_fuzzy_match( # noqa: PLR0914 - content: str, search_text: str, threshold: float = 0.9 - ) -> FuzzyMatch | None: - content_lines = content.split("\n") - search_lines = search_text.split("\n") - window_size = len(search_lines) - - if window_size == 0: - return None - - non_empty_search = [line for line in search_lines if line.strip()] - if not non_empty_search: - return None - - first_anchor = non_empty_search[0] - last_anchor = ( - non_empty_search[-1] if len(non_empty_search) > 1 else first_anchor - ) - - candidate_starts = set() - spread = 5 - - for i, line in enumerate(content_lines): - if first_anchor in line or last_anchor in line: - start_min = max(0, i - spread) - start_max = min(len(content_lines) - window_size + 1, i + spread + 1) - for s in range(start_min, start_max): - candidate_starts.add(s) - - if not candidate_starts: - max_positions = min(len(content_lines) - window_size + 1, 100) - candidate_starts = set(range(0, max_positions)) - - best_match = None - best_similarity = 0.0 - - for start in candidate_starts: - end = start + window_size - window_text = "\n".join(content_lines[start:end]) - - matcher = difflib.SequenceMatcher(None, search_text, window_text) - similarity = matcher.ratio() - - if similarity >= threshold and similarity > best_similarity: - best_similarity = similarity - best_match = FuzzyMatch( - similarity=similarity, - start_line=start + 1, # 1-based line numbers - end_line=end, - text=window_text, - ) - - return best_match - - @final - @staticmethod - def _create_unified_diff( - text1: str, text2: str, label1: str = "SEARCH", label2: str = "CLOSEST MATCH" - ) -> str: - lines1 = text1.splitlines(keepends=True) - lines2 = text2.splitlines(keepends=True) - - lines1 = [line if line.endswith("\n") else line + "\n" for line in lines1] - lines2 = [line if line.endswith("\n") else line + "\n" for line in lines2] - - diff = difflib.unified_diff( - lines1, lines2, fromfile=label1, tofile=label2, lineterm="", n=3 - ) - - diff_lines = list(diff) - - if diff_lines and not diff_lines[0].startswith("==="): - diff_lines.insert(2, "=" * 67 + "\n") - - result = "".join(diff_lines) - - max_chars = 2000 - if len(result) > max_chars: - result = result[:max_chars] + "\n...(diff truncated)" - - return result.rstrip() - - @final - @staticmethod - def _parse_search_replace_blocks(content: str) -> list[SearchReplaceBlock]: - """Parse SEARCH/REPLACE blocks from content. - - Supports two formats: - 1. With code block fences (```...```) - 2. Without code block fences - """ - matches = SEARCH_REPLACE_BLOCK_WITH_FENCE_RE.findall(content) - - if not matches: - matches = SEARCH_REPLACE_BLOCK_RE.findall(content) - - return [ - SearchReplaceBlock( - search=search.rstrip("\r\n"), replace=replace.rstrip("\r\n") - ) - for search, replace in matches - ] - - @final - @staticmethod - def _find_search_context( - content: str, search_text: str, max_context: int = 5 - ) -> str: - lines = content.split("\n") - search_lines = search_text.split("\n") - - if not search_lines: - return "Search text is empty" - - first_search_line = search_lines[0].strip() - if not first_search_line: - return "First line of search text is empty or whitespace only" - - matches = [] - for i, line in enumerate(lines): - if first_search_line in line: - matches.append(i) - - if not matches: - return f"First search line '{first_search_line}' not found anywhere in file" - - context_lines = [] - for match_idx in matches[:3]: - start = max(0, match_idx - max_context) - end = min(len(lines), match_idx + max_context + 1) - - context_lines.append(f"\nPotential match area around line {match_idx + 1}:") - for i in range(start, end): - marker = ">>>" if i == match_idx else " " - context_lines.append(f"{marker} {i + 1:3d}: {lines[i]}") - - return "\n".join(context_lines) diff --git a/vibe/core/tools/builtins/write_file.py b/vibe/core/tools/builtins/write_file.py index 531407d..c7d23fc 100644 --- a/vibe/core/tools/builtins/write_file.py +++ b/vibe/core/tools/builtins/write_file.py @@ -26,15 +26,11 @@ from vibe.core.types import ToolResultEvent, ToolStreamEvent class WriteFileArgs(BaseModel): path: str content: str - overwrite: bool = Field( - default=False, description="Must be set to true to overwrite an existing file." - ) class WriteFileResult(BaseModel): path: str bytes_written: int - file_existed: bool content: str @@ -53,24 +49,22 @@ class WriteFile( ToolUIData[WriteFileArgs, WriteFileResult], ): description: ClassVar[str] = ( - "Create or overwrite a UTF-8 file. Fails if file exists unless 'overwrite=True'." + "Create a UTF-8 file. Fails if the file already exists; use search_replace to edit." ) @classmethod def format_call_display(cls, args: WriteFileArgs) -> ToolCallDisplay: tag = " (scratchpad)" if is_scratchpad_path(args.path) else "" - overwrite = " (overwrite)" if args.overwrite else "" return ToolCallDisplay( - summary=f"Writing {args.path}{overwrite}{tag}", content=args.content + summary=f"Writing {args.path}{tag}", content=args.content ) @classmethod def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: if isinstance(event.result, WriteFileResult): - action = "Overwritten" if event.result.file_existed else "Created" tag = " (scratchpad)" if is_scratchpad_path(event.result.path) else "" return ToolResultDisplay( - success=True, message=f"{action} {Path(event.result.path).name}{tag}" + success=True, message=f"Created {Path(event.result.path).name}{tag}" ) return ToolResultDisplay(success=True, message="File written") @@ -96,18 +90,15 @@ class WriteFile( async def run( self, args: WriteFileArgs, ctx: InvokeContext | None = None ) -> AsyncGenerator[ToolStreamEvent | WriteFileResult, None]: - file_path, file_existed, content_bytes = self._prepare_and_validate_path(args) + file_path, content_bytes = self._prepare_and_validate_path(args) await self._write_file(args, file_path) yield WriteFileResult( - path=str(file_path), - bytes_written=content_bytes, - file_existed=file_existed, - content=args.content, + path=str(file_path), bytes_written=content_bytes, content=args.content ) - def _prepare_and_validate_path(self, args: WriteFileArgs) -> tuple[Path, bool, int]: + def _prepare_and_validate_path(self, args: WriteFileArgs) -> tuple[Path, int]: if not args.path.strip(): raise ToolError("Path cannot be empty") @@ -122,11 +113,9 @@ class WriteFile( file_path = Path.cwd() / file_path file_path = file_path.resolve() - file_existed = file_path.exists() - - if file_existed and not args.overwrite: + if file_path.exists(): raise ToolError( - f"File '{file_path}' exists. Set overwrite=True to replace." + f"File '{file_path}' already exists. Use search_replace to edit it." ) if self.config.create_parent_dirs: @@ -134,13 +123,17 @@ class WriteFile( elif not file_path.parent.exists(): raise ToolError(f"Parent directory does not exist: {file_path.parent}") - return file_path, file_existed, content_bytes + return file_path, content_bytes async def _write_file(self, args: WriteFileArgs, file_path: Path) -> None: try: async with await anyio.Path(file_path).open( - mode="w", encoding="utf-8" + mode="x", encoding="utf-8" ) as f: await f.write(args.content) + except FileExistsError as e: + raise ToolError( + f"File '{file_path}' already exists. Use search_replace to edit it." + ) from e except Exception as e: raise ToolError(f"Error writing {file_path}: {e}") from e diff --git a/vibe/core/tools/connectors/__init__.py b/vibe/core/tools/connectors/__init__.py index 4b958b7..dcf7505 100644 --- a/vibe/core/tools/connectors/__init__.py +++ b/vibe/core/tools/connectors/__init__.py @@ -4,5 +4,6 @@ from vibe.core.tools.connectors.connector_registry import ( ConnectorAuthAction, ConnectorRegistry, ) +from vibe.core.tools.connectors.counts import compute_connector_counts -__all__ = ["ConnectorAuthAction", "ConnectorRegistry"] +__all__ = ["ConnectorAuthAction", "ConnectorRegistry", "compute_connector_counts"] diff --git a/vibe/core/tools/connectors/counts.py b/vibe/core/tools/connectors/counts.py new file mode 100644 index 0000000..7ba0e3d --- /dev/null +++ b/vibe/core/tools/connectors/counts.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from vibe.core.tools.connectors.connector_registry import ConnectorRegistry + +if TYPE_CHECKING: + from vibe.core.config import VibeConfig + + +def compute_connector_counts( + config: VibeConfig, connector_registry: ConnectorRegistry | None +) -> tuple[int, int]: + if connector_registry is None: + return (0, 0) + aliases = connector_registry.get_connector_names() + if not aliases: + return (0, 0) + by_name = config.connectors_by_name() + connected = sum( + (cfg := by_name.get(alias)) is not None + and not cfg.disabled + and connector_registry.is_connected(alias) + for alias in aliases + ) + return (connected, len(aliases)) diff --git a/vibe/core/tools/manager.py b/vibe/core/tools/manager.py index 32e2b90..8019f31 100644 --- a/vibe/core/tools/manager.py +++ b/vibe/core/tools/manager.py @@ -87,7 +87,7 @@ class ToolManager: self._lock = threading.Lock() self._mcp_integrated = False - self._available: dict[str, type[BaseTool]] = { + self._all_tools: dict[str, type[BaseTool]] = { cls.get_name(): cls for cls in self._iter_tool_classes(self._search_paths) } if not defer_mcp: @@ -190,14 +190,14 @@ class ToolManager: @property def registered_tools(self) -> dict[str, type[BaseTool]]: with self._lock: - return dict(self._available) + return dict(self._all_tools) @property def available_tools(self) -> dict[str, type[BaseTool]]: with self._lock: runtime_available = { name: cls - for name, cls in self._available.items() + for name, cls in self._all_tools.items() if self._is_tool_available(cls) } @@ -254,20 +254,15 @@ class ToolManager: elif srv.disabled_tools: per_source_disabled[key] = set(srv.disabled_tools) - # Track connector names that have explicit config entries - configured_connectors = {cfg.name for cfg in self._config.connectors} - for cfg in self._config.connectors: - key = (cfg.name, True) - if cfg.disabled: - disabled_sources.add(key) - elif cfg.disabled_tools: - per_source_disabled[key] = set(cfg.disabled_tools) + if cfg.disabled_tools and not cfg.disabled: + per_source_disabled[(cfg.name, True)] = set(cfg.disabled_tools) - # Disable connectors discovered but not in config (new connectors) if self._connector_registry is not None: + by_name = self._config.connectors_by_name() for name in self._connector_registry.get_connector_names(): - if name not in configured_connectors: + cfg = by_name.get(name) + if cfg is None or cfg.disabled: disabled_sources.add((name, True)) return disabled_sources, per_source_disabled @@ -314,7 +309,7 @@ class ToolManager: return with self._lock: - self._available = {**self._available, **mcp_tools} + self._all_tools = {**self._all_tools, **mcp_tools} self._mcp_integrated = True logger.info( "MCP integration registered %d tools (via registry)", len(mcp_tools) @@ -324,22 +319,22 @@ class ToolManager: """Remove stale connector tool classes and cached instances.""" stale_keys = [ name - for name, cls in self._available.items() + for name, cls in self._all_tools.items() if issubclass(cls, MCPTool) and cls.is_connector() ] for key in stale_keys: - self._available.pop(key, None) + self._all_tools.pop(key, None) self._instances.pop(key, None) def _purge_mcp_state(self) -> None: """Remove stale MCP tool classes and cached instances.""" stale_keys = [ name - for name, cls in self._available.items() + for name, cls in self._all_tools.items() if issubclass(cls, MCPTool) and not cls.is_connector() ] for key in stale_keys: - self._available.pop(key, None) + self._all_tools.pop(key, None) self._instances.pop(key, None) def integrate_connectors(self) -> None: @@ -364,7 +359,7 @@ class ToolManager: with self._lock: self._purge_connector_state() - self._available.update(connector_tools) + self._all_tools.update(connector_tools) logger.info(f"Connector integration registered {len(connector_tools)} tools") async def refresh_remote_tools_async(self) -> None: @@ -414,7 +409,7 @@ class ToolManager: def get_tool_config(self, tool_name: str) -> BaseToolConfig: with self._lock: - tool_class = self._available.get(tool_name) + tool_class = self._all_tools.get(tool_name) if tool_class: config_class = tool_class._get_tool_config_class() @@ -444,12 +439,13 @@ class ToolManager: if tool_name in self._instances: return self._instances[tool_name] - with self._lock: - if tool_name not in self._available: - raise NoSuchToolError( - f"Unknown tool: {tool_name}. Available: {list(self._available.keys())}" - ) - tool_class = self._available[tool_name] + available = self.available_tools + if tool_name not in available: + raise NoSuchToolError( + f"Unknown or disabled tool: {tool_name}. " + f"Available: {list(available.keys())}" + ) + tool_class = available[tool_name] self._instances[tool_name] = tool_class.from_config( lambda: self.get_tool_config(tool_name) ) diff --git a/vibe/core/trusted_folders.py b/vibe/core/trusted_folders.py index 9146ac2..4dc4b57 100644 --- a/vibe/core/trusted_folders.py +++ b/vibe/core/trusted_folders.py @@ -8,28 +8,77 @@ import tomli_w from vibe.core.paths import ( AGENTS_MD_FILENAME, TRUSTED_FOLDERS_FILE, - walk_local_config_dirs, + find_local_config_dirs, ) def has_agents_md_file(path: Path) -> bool: - return (path / AGENTS_MD_FILENAME).exists() + return (path / AGENTS_MD_FILENAME).is_file() + + +def _is_git_repo_root(path: Path) -> bool: + git_dir = path / ".git" + return git_dir.is_dir() and (git_dir / "HEAD").is_file() + + +def find_git_repo_ancestor(path: Path) -> Path | None: + """Closest ancestor (or *path*) with a real ``.git/HEAD``. + + Excludes the home directory and the filesystem root. + """ + resolved = path.expanduser().resolve() + home = Path.home().resolve() + current = resolved + while current not in {home, current.parent}: + if _is_git_repo_root(current): + return current + current = current.parent + return None def find_trustable_files(path: Path) -> list[str]: - """Return relative paths of files/dirs that would modify the agent's behavior.""" + """Relative paths of files/dirs under *path* that would modify agent behavior.""" resolved = path.resolve() found: list[str] = [] if has_agents_md_file(path): found.append(AGENTS_MD_FILENAME) - for config_dir in walk_local_config_dirs(path).config_dirs: + for config_dir in find_local_config_dirs(path).config_dirs: label = f"{config_dir.relative_to(resolved)}/" if label not in found: found.append(label) - return found + return sorted(found) + + +def find_repo_trustable_files_for_cwd(cwd: Path, repo_root: Path | None) -> list[str]: + """Repo-context files that influence *cwd* when inside a git repository. + + Includes: + - all trustable files at ``repo_root`` + - all ``AGENTS.md`` files on ancestors between ``cwd`` and ``repo_root`` + """ + if repo_root is None: + return [] + + resolved_cwd = cwd.resolve() + resolved_repo_root = repo_root.resolve() + if resolved_repo_root not in resolved_cwd.parents: + return [] + + found = set(find_trustable_files(resolved_repo_root)) + + current = resolved_cwd.parent + while current != resolved_repo_root: + if has_agents_md_file(current): + relative_path = (current / AGENTS_MD_FILENAME).relative_to( + resolved_repo_root + ) + found.add(relative_path.as_posix()) + current = current.parent + + return sorted(found) class TrustedFoldersManager: @@ -72,38 +121,38 @@ class TrustedFoldersManager: except OSError: pass - def is_trusted(self, path: Path) -> bool | None: - """Check trust walking up from *path* to filesystem root. - - The first ancestor (or *path* itself) found in either the trusted, - session-trusted, or untrusted list wins. Returns ``None`` when no - decision exists. - """ + def _closest_decision(self, path: Path) -> tuple[bool, Path] | None: + """``(trusted, ancestor)`` for the closest decision, ``None`` if undecided.""" current = Path(self._normalize_path(path)) while True: s = str(current) if s in self._trusted or s in self._session_trusted: - return True + return True, current if s in self._untrusted: - return False - parent = current.parent - if parent == current: - break - current = parent - return None + return False, current + if current.parent == current: + return None + current = current.parent + + def is_trusted(self, path: Path) -> bool | None: + """Tri-state closest decision; ``None`` when no ancestor has one.""" + match self._closest_decision(path): + case (trusted, _): + return trusted + case None: + return None + + def is_explicitly_untrusted(self, path: Path) -> bool: + """*path* literally in the untrusted list (no ancestor walk).""" + return self._normalize_path(path) in self._untrusted def find_trust_root(self, path: Path) -> Path | None: - """Return the closest ancestor (or *path* itself) explicitly trusted.""" - current = Path(self._normalize_path(path)) - while True: - s = str(current) - if s in self._trusted or s in self._session_trusted: - return current - parent = current.parent - if parent == current: - break - current = parent - return None + """Closest explicitly trusted ancestor; ``None`` if a closer untrust blocks.""" + match self._closest_decision(path): + case (True, root): + return root + case _: + return None def add_trusted(self, path: Path) -> None: normalized = self._normalize_path(path) diff --git a/vibe/core/types.py b/vibe/core/types.py index 21ea638..2c53259 100644 --- a/vibe/core/types.py +++ b/vibe/core/types.py @@ -216,11 +216,25 @@ class ApprovalResponse(StrEnum): NO = "n" +IMAGE_EXTENSIONS: frozenset[str] = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"}) +MAX_IMAGE_BYTES: int = 10 * 1024 * 1024 +MAX_IMAGES_PER_MESSAGE: int = 8 + + +class ImageAttachment(BaseModel): + model_config = ConfigDict(extra="ignore") + + path: Path + alias: str + mime_type: str + + class LLMMessage(BaseModel): model_config = ConfigDict(extra="ignore") role: Role content: Content | None = None + images: list[ImageAttachment] | None = None injected: bool = False reasoning_content: Content | None = None reasoning_state: list[str] | None = None @@ -255,6 +269,7 @@ class LLMMessage(BaseModel): "tool_calls": getattr(v, "tool_calls", None), "name": getattr(v, "name", None), "tool_call_id": getattr(v, "tool_call_id", None), + "images": getattr(v, "images", None), "message_id": getattr(v, "message_id", None) or (str(uuid4()) if role != "tool" else None), } @@ -317,6 +332,7 @@ class LLMMessage(BaseModel): return LLMMessage( role=self.role, content=content, + images=self.images if self.images is not None else other.images, reasoning_content=reasoning_content, reasoning_state=reasoning_state, reasoning_signature=reasoning_signature, diff --git a/vibe/core/utils/io.py b/vibe/core/utils/io.py index d7e514f..67fe4d8 100644 --- a/vibe/core/utils/io.py +++ b/vibe/core/utils/io.py @@ -1,9 +1,14 @@ from __future__ import annotations -from collections.abc import Iterator +import asyncio +from collections.abc import AsyncIterator, Iterator +import contextlib +from contextlib import asynccontextmanager import locale import os from pathlib import Path +import shutil +import time from typing import NamedTuple import anyio @@ -106,3 +111,106 @@ async def read_safe_async( """Async :func:`read_safe` (``anyio``).""" raw = await anyio.Path(path).read_bytes() return decode_safe(raw, raise_on_error=raise_on_error) + + +class BoundedReadResult(NamedTuple): + r"""A bounded slice of a file's lines plus truncation metadata. + + ``lines`` are decoded and ``\n``-normalized, without trailing newlines. + ``total_lines`` is ``None`` when the read stopped early at the line or byte + budget (the true total is unknown without scanning the rest of the file); + otherwise it is the number of lines in the file. ``was_truncated`` is true + when the read stopped before reaching end of file. + """ + + lines: list[str] + total_lines: int | None + was_truncated: bool + + +def read_lines_safe( + path: Path, *, start_line: int = 1, limit: int, max_bytes: int +) -> BoundedReadResult: + r"""Read up to ``limit`` lines from ``start_line`` (1-indexed) bounded by bytes. + + Streams the file line-by-line in binary and stops once ``limit`` lines or + ``max_bytes`` of selected content have been collected, so large files are + never loaded whole. The collected bytes are decoded once via + :func:`decode_safe`, which also normalizes ``\r\n``/``\r`` to ``\n``. + """ + raw_lines: list[bytes] = [] + bytes_read = 0 + line_number = 0 + was_truncated = True + + with path.open("rb") as f: + while raw_line := f.readline(): + line_number += 1 + if line_number < start_line: + continue + if len(raw_lines) >= limit: + break + if bytes_read + len(raw_line) > max_bytes: + remaining = max_bytes - bytes_read + if remaining > 0: + raw_lines.append(raw_line[:remaining]) + break + raw_lines.append(raw_line) + bytes_read += len(raw_line) + else: + was_truncated = False + + total_lines = None if was_truncated else line_number + lines = decode_safe(b"".join(raw_lines)).text.splitlines() + return BoundedReadResult(lines, total_lines, was_truncated) + + +async def read_lines_safe_async( + path: Path, *, start_line: int = 1, limit: int, max_bytes: int +) -> BoundedReadResult: + """Async :func:`read_lines_safe` (runs the blocking read in a thread).""" + return await asyncio.to_thread( + read_lines_safe, path, start_line=start_line, limit=limit, max_bytes=max_bytes + ) + + +_FILE_WRITE_LOCKS: dict[str, asyncio.Lock] = {} +_FILE_WRITE_LOCK_LOOP: asyncio.AbstractEventLoop | None = None + + +def _get_lock(path: Path) -> asyncio.Lock: + global _FILE_WRITE_LOCK_LOOP + loop = asyncio.get_running_loop() + if _FILE_WRITE_LOCK_LOOP is not loop: + _FILE_WRITE_LOCKS.clear() + _FILE_WRITE_LOCK_LOOP = loop + key = str(path.resolve()) + lock = _FILE_WRITE_LOCKS.get(key) + if lock is None: + lock = _FILE_WRITE_LOCKS[key] = asyncio.Lock() + return lock + + +@asynccontextmanager +async def file_write_lock(path: Path) -> AsyncIterator[None]: + async with _get_lock(path): + yield + + +async def atomic_replace( + path: Path, content: str, *, encoding: str = "utf-8", newline: str | None = None +) -> None: + target = Path(path) + tmp = target.parent / f".{target.name}.tmp.{os.getpid()}.{time.time_ns()}" + try: + async with await anyio.Path(tmp).open( + mode="w", encoding=encoding, newline=newline + ) as f: + await f.write(content) + with contextlib.suppress(FileNotFoundError): + shutil.copymode(target, tmp) + os.replace(tmp, target) + except BaseException: + with contextlib.suppress(FileNotFoundError): + tmp.unlink() + raise diff --git a/vibe/core/utils/retry.py b/vibe/core/utils/retry.py index f5e120e..97ad421 100644 --- a/vibe/core/utils/retry.py +++ b/vibe/core/utils/retry.py @@ -3,13 +3,26 @@ from __future__ import annotations import asyncio from collections.abc import AsyncGenerator, Awaitable, Callable import functools +import logging import httpx +logger = logging.getLogger("vibe") + +_RETRYABLE_REQUEST_ERRORS: tuple[type[httpx.RequestError], ...] = ( + httpx.TimeoutException, + httpx.ConnectError, + httpx.ReadError, + httpx.WriteError, + httpx.RemoteProtocolError, +) + def _is_retryable_http_error(e: Exception) -> bool: if isinstance(e, httpx.HTTPStatusError): return e.response.status_code in {408, 409, 425, 429, 500, 502, 503, 504, 529} + if isinstance(e, _RETRYABLE_REQUEST_ERRORS): + return True return False @@ -43,6 +56,14 @@ def async_retry[T, **P]( current_delay = (delay_seconds * (backoff_factor**attempt)) + ( 0.05 * attempt ) + logger.warning( + "Retrying %s after error attempt=%d/%d delay=%.2fs error=%r", + func.__qualname__, + attempt + 1, + tries, + current_delay, + e, + ) await asyncio.sleep(current_delay) continue raise e @@ -81,19 +102,33 @@ def async_generator_retry[T, **P]( async def wrapper(*args: P.args, **kwargs: P.kwargs) -> AsyncGenerator[T]: last_exc = None for attempt in range(tries): + generator = func(*args, **kwargs) try: - async for item in func(*args, **kwargs): - yield item + first_item = await anext(generator) + except StopAsyncIteration: return except Exception as e: last_exc = e + await generator.aclose() if attempt < tries - 1 and is_retryable(e): current_delay = (delay_seconds * (backoff_factor**attempt)) + ( 0.05 * attempt ) + logger.warning( + "Retrying %s after error attempt=%d/%d delay=%.2fs error=%r", + func.__qualname__, + attempt + 1, + tries, + current_delay, + e, + ) await asyncio.sleep(current_delay) continue - raise e + raise + yield first_item + async for item in generator: + yield item + return raise RuntimeError( f"Retries exhausted. Last error: {last_exc}" ) from last_exc diff --git a/vibe/setup/auth/__init__.py b/vibe/setup/auth/__init__.py index 3bdfe92..9d1bb11 100644 --- a/vibe/setup/auth/__init__.py +++ b/vibe/setup/auth/__init__.py @@ -3,8 +3,12 @@ from __future__ import annotations from vibe.setup.auth.auth_state import AuthState, AuthStateKind, assess_auth_state from vibe.setup.auth.browser_sign_in import ( BrowserSignInAttempt, + BrowserSignInAttemptStarted, + BrowserSignInEvent, + BrowserSignInEventCallback, BrowserSignInService, BrowserSignInStatus, + BrowserSignInStatusChanged, ) from vibe.setup.auth.browser_sign_in_gateway import ( BrowserSignInError, @@ -19,13 +23,17 @@ __all__ = [ "AuthState", "AuthStateKind", "BrowserSignInAttempt", + "BrowserSignInAttemptStarted", "BrowserSignInError", "BrowserSignInErrorCode", + "BrowserSignInEvent", + "BrowserSignInEventCallback", "BrowserSignInGateway", "BrowserSignInPollResult", "BrowserSignInProcess", "BrowserSignInService", "BrowserSignInStatus", + "BrowserSignInStatusChanged", "HttpBrowserSignInGateway", "assess_auth_state", ] diff --git a/vibe/setup/auth/browser_sign_in.py b/vibe/setup/auth/browser_sign_in.py index e024c29..f3b41dc 100644 --- a/vibe/setup/auth/browser_sign_in.py +++ b/vibe/setup/auth/browser_sign_in.py @@ -24,12 +24,27 @@ class BrowserSignInStatus(StrEnum): COMPLETED = "completed" -StatusCallback = Callable[[BrowserSignInStatus], None] BrowserOpener = Callable[[str], bool] SleepFn = Callable[[float], Awaitable[None]] NowFn = Callable[[], datetime] +@dataclass(frozen=True) +class BrowserSignInAttemptStarted: + sign_in_url: str + # Keeps attempt expiry available to future UIs without changing the event contract. + expires_at: datetime + + +@dataclass(frozen=True) +class BrowserSignInStatusChanged: + status: BrowserSignInStatus + + +BrowserSignInEvent = BrowserSignInAttemptStarted | BrowserSignInStatusChanged +BrowserSignInEventCallback = Callable[[BrowserSignInEvent], None] + + @dataclass(frozen=True) class BrowserSignInAttempt: process_id: str @@ -71,26 +86,39 @@ class BrowserSignInService: code_verifier=verifier, ) - async def complete_attempt( + async def complete_attempt(self, attempt: BrowserSignInAttempt) -> str: + return await self._complete_attempt(attempt) + + async def authenticate( + self, event_callback: BrowserSignInEventCallback | None = None + ) -> str: + attempt = await self.start_attempt() + event = BrowserSignInAttemptStarted( + sign_in_url=attempt.sign_in_url, expires_at=attempt.expires_at + ) + if event_callback is not None: + event_callback(event) + self._emit_status(event_callback, BrowserSignInStatus.OPENING_BROWSER) + self._open_browser_or_raise(attempt.sign_in_url) + return await self._complete_attempt(attempt, event_callback=event_callback) + + async def _complete_attempt( self, attempt: BrowserSignInAttempt, - status_callback: StatusCallback | None = None, + *, + event_callback: BrowserSignInEventCallback | None = None, ) -> str: - self._emit(status_callback, BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN) + self._emit_status( + event_callback, BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN + ) exchange_token = await self._wait_for_completion(attempt) - self._emit(status_callback, BrowserSignInStatus.EXCHANGING) + self._emit_status(event_callback, BrowserSignInStatus.EXCHANGING) api_key = await self._gateway.exchange( attempt.process_id, exchange_token, attempt.code_verifier ) - self._emit(status_callback, BrowserSignInStatus.COMPLETED) + self._emit_status(event_callback, BrowserSignInStatus.COMPLETED) return api_key - async def authenticate(self, status_callback: StatusCallback | None = None) -> str: - attempt = await self.start_attempt() - self._emit(status_callback, BrowserSignInStatus.OPENING_BROWSER) - self._open_browser_or_raise(attempt.sign_in_url) - return await self.complete_attempt(attempt, status_callback=status_callback) - async def _wait_for_completion(self, attempt: BrowserSignInAttempt) -> str: consecutive_poll_failures = 0 while self._now() < attempt.expires_at: @@ -148,11 +176,11 @@ class BrowserSignInService: ) await self._sleep(min(self._poll_interval, remaining_seconds)) - def _emit( - self, callback: StatusCallback | None, status: BrowserSignInStatus + def _emit_status( + self, callback: BrowserSignInEventCallback | None, status: BrowserSignInStatus ) -> None: if callback is not None: - callback(status) + callback(BrowserSignInStatusChanged(status=status)) def _open_browser_or_raise(self, sign_in_url: str) -> None: try: diff --git a/vibe/setup/onboarding/__init__.py b/vibe/setup/onboarding/__init__.py index 1883528..c697872 100644 --- a/vibe/setup/onboarding/__init__.py +++ b/vibe/setup/onboarding/__init__.py @@ -7,6 +7,7 @@ from typing import Any from rich import print as rprint from textual.app import App +from vibe.cli.clipboard import try_copy_text_to_clipboard from vibe.core.config import VibeConfig from vibe.core.paths import GLOBAL_ENV_FILE from vibe.core.telemetry.types import EntrypointMetadata @@ -19,7 +20,11 @@ from vibe.setup.onboarding.screens import ( ThemeSelectionScreen, WelcomeScreen, ) -from vibe.setup.onboarding.screens.browser_sign_in import SUCCESS_EXIT_DELAY_SECONDS +from vibe.setup.onboarding.screens.browser_sign_in import ( + SIGN_IN_URL_HELP_DELAY_SECONDS, + SUCCESS_EXIT_DELAY_SECONDS, + CopySignInUrl, +) class OnboardingApp(App[str | None]): @@ -32,6 +37,8 @@ class OnboardingApp(App[str | None]): | None = None, entrypoint_metadata: EntrypointMetadata | None = None, browser_sign_in_success_delay: float = SUCCESS_EXIT_DELAY_SECONDS, + browser_sign_in_url_help_delay: float = SIGN_IN_URL_HELP_DELAY_SECONDS, + copy_sign_in_url: CopySignInUrl | None = None, **kwargs: Any, ) -> None: super().__init__(**kwargs) @@ -45,6 +52,8 @@ class OnboardingApp(App[str | None]): self._vibe_base_url = config.vibe_base_url self._entrypoint_metadata = entrypoint_metadata self._browser_sign_in_success_delay = browser_sign_in_success_delay + self._browser_sign_in_url_help_delay = browser_sign_in_url_help_delay + self._copy_sign_in_url = copy_sign_in_url or self._copy_sign_in_url_to_clipboard self._browser_sign_in_service_factory = self._resolve_browser_sign_in_factory( browser_sign_in_service_factory ) @@ -72,8 +81,10 @@ class OnboardingApp(App[str | None]): BrowserSignInScreen( self._provider, self._browser_sign_in_service_factory, + copy_sign_in_url=self._copy_sign_in_url, entrypoint_metadata=self._entrypoint_metadata, success_exit_delay=self._browser_sign_in_success_delay, + sign_in_url_help_delay=self._browser_sign_in_url_help_delay, ), "browser_sign_in", ) @@ -109,6 +120,9 @@ class OnboardingApp(App[str | None]): or self._build_browser_sign_in_service_factory() ) + def _copy_sign_in_url_to_clipboard(self, text: str) -> bool: + return try_copy_text_to_clipboard(text) + def run_onboarding( app: App | None = None, *, entrypoint_metadata: EntrypointMetadata | None = None diff --git a/vibe/setup/onboarding/context.py b/vibe/setup/onboarding/context.py index de13912..1e930e9 100644 --- a/vibe/setup/onboarding/context.py +++ b/vibe/setup/onboarding/context.py @@ -9,7 +9,7 @@ from pydantic import BaseModel, Field, TypeAdapter, ValidationError from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig from vibe.core.config._settings import ( - DEFAULT_ACTIVE_MODEL, + DEFAULT_ACTIVE_MODEL_CONFIG, DEFAULT_MODELS, DEFAULT_PROVIDERS, DEFAULT_VIBE_BASE_URL, @@ -29,7 +29,7 @@ def _default_model_payloads() -> list[dict[str, Any]]: class _OnboardingSnapshot(BaseModel): - active_model: str = DEFAULT_ACTIVE_MODEL + active_model: str = DEFAULT_ACTIVE_MODEL_CONFIG.alias vibe_base_url: str = DEFAULT_VIBE_BASE_URL providers: list[Any] = Field(default_factory=_default_provider_payloads) models: list[Any] = Field(default_factory=_default_model_payloads) @@ -168,7 +168,7 @@ def _resolve_provider( models = _validated_payloads(snapshot.models, ModelConfig) - for model_alias in (active_model, DEFAULT_ACTIVE_MODEL): + for model_alias in (active_model, DEFAULT_ACTIVE_MODEL_CONFIG.alias): for model in models: if model.alias != model_alias: continue diff --git a/vibe/setup/onboarding/onboarding.tcss b/vibe/setup/onboarding/onboarding.tcss index cf46f27..3ac8049 100644 --- a/vibe/setup/onboarding/onboarding.tcss +++ b/vibe/setup/onboarding/onboarding.tcss @@ -326,8 +326,22 @@ ThemeSelectionScreen #enter-hint { width: 100%; color: $foreground-muted; text-align: center; + margin-top: 2; + padding-left: 2; +} + +#browser-sign-in-url { + width: 100%; + height: auto; + color: $foreground-muted; + link-style: none; + link-style-hover: none; margin-top: 1; padding-left: 2; + + &:ansi { + text-style: dim; + } } /* ============================================================================= diff --git a/vibe/setup/onboarding/screens/browser_sign_in.py b/vibe/setup/onboarding/screens/browser_sign_in.py index 4d511e3..9fd829d 100644 --- a/vibe/setup/onboarding/screens/browser_sign_in.py +++ b/vibe/setup/onboarding/screens/browser_sign_in.py @@ -2,16 +2,18 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import IntEnum from typing import ClassVar, Literal +from rich.markup import escape from rich.text import Text from textual.app import ComposeResult from textual.binding import Binding, BindingType from textual.containers import Center, Horizontal, Vertical from textual.reactive import reactive from textual.timer import Timer +from textual.widgets import Static from textual.worker import Worker from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat @@ -20,10 +22,13 @@ from vibe.core.config import ProviderConfig from vibe.core.logger import logger from vibe.core.telemetry.types import EntrypointMetadata from vibe.setup.auth import ( + BrowserSignInAttemptStarted, BrowserSignInError, BrowserSignInErrorCode, + BrowserSignInEvent, BrowserSignInService, BrowserSignInStatus, + BrowserSignInStatusChanged, ) from vibe.setup.auth.api_key_persistence import ( persist_api_key, @@ -35,7 +40,12 @@ from vibe.setup.onboarding.gradient_text import GRADIENT_COLORS, append_gradient PENDING_HINT = "Press M to enter API key manually - Esc to cancel" ERROR_HINT = "Press R to retry - Press M to enter API key manually - Esc to cancel" SUCCESS_HINT = "Finishing setup..." +SIGN_IN_URL_HELP_PREFIX = "If your browser did not open, " +SIGN_IN_URL_COPY_LABEL = "copy this URL" +SIGN_IN_URL_HELP_SUFFIX = " (press C)." +SIGN_IN_URL_REVEAL_PREFIX = "Copy failed. Open this URL manually:" SUCCESS_EXIT_DELAY_SECONDS: float = 2.0 +SIGN_IN_URL_HELP_DELAY_SECONDS: float = 4.0 WAITING_FOR_AUTHENTICATION_MESSAGE = "Waiting for authentication..." STEP_DESCRIPTIONS = [ ("Open browser", "Your browser should open automatically", "Browser opened"), @@ -49,6 +59,9 @@ UNEXPECTED_ERROR_MESSAGE = ( ERROR_MESSAGES = { BrowserSignInErrorCode.POLL_FAILED: "We couldn't complete sign-in. Please try again." } +COPY_URL_SUCCESS_MESSAGE = "Sign-in URL copied to clipboard" + +CopySignInUrl = Callable[[str], bool] class BrowserSignInStep(IntEnum): @@ -61,9 +74,21 @@ class BrowserSignInStep(IntEnum): class BrowserSignInViewState: step: BrowserSignInStep message: str - hint: str variant: Literal["pending", "error", "success"] running: bool + sign_in_url: str | None = None + show_sign_in_url_help: bool = False + reveal_sign_in_url: bool = False + + @property + def hint(self) -> str: + if self.variant == "success": + return SUCCESS_HINT + + if self.variant == "error": + return ERROR_HINT + + return PENDING_HINT @dataclass(frozen=True) @@ -79,7 +104,6 @@ class BrowserSignInScreen(OnboardingScreen): BrowserSignInViewState( step=BrowserSignInStep.OPEN, message="Getting things ready...", - hint=PENDING_HINT, variant="pending", running=False, ), @@ -88,6 +112,7 @@ class BrowserSignInScreen(OnboardingScreen): BINDINGS: ClassVar[list[BindingType]] = [ Binding("r", "retry", "Retry", show=False), + Binding("c", "copy_url", "Copy URL", show=False), Binding("m", "manual", "Manual", show=False), Binding("ctrl+c", "cancel", "Cancel", show=False), Binding("escape", "cancel", "Cancel", show=False), @@ -98,28 +123,33 @@ class BrowserSignInScreen(OnboardingScreen): provider: ProviderConfig, browser_sign_in_factory: Callable[[], BrowserSignInService], *, + copy_sign_in_url: CopySignInUrl, entrypoint_metadata: EntrypointMetadata | None = None, success_exit_delay: float = SUCCESS_EXIT_DELAY_SECONDS, + sign_in_url_help_delay: float = SIGN_IN_URL_HELP_DELAY_SECONDS, ) -> None: super().__init__() self.provider = provider self._browser_sign_in_factory = browser_sign_in_factory + self._copy_sign_in_url = copy_sign_in_url self._entrypoint_metadata = entrypoint_metadata self._success_exit_delay = success_exit_delay + self._sign_in_url_help_delay = sign_in_url_help_delay self._attempt_number = 0 self._active_attempt_number: int | None = None self._worker: Worker[None] | None = None self._gradient_offset = 0 self._gradient_timer: Timer | None = None + self._sign_in_url_help_timer: Timer | None = None self._initial_state = BrowserSignInViewState( step=BrowserSignInStep.OPEN, message="Getting things ready...", - hint=PENDING_HINT, variant="pending", running=False, ) self._step_widgets: list[BrowserSignInStepWidgets] = [] self._title_widget: NoMarkupStatic + self._url_widget: Static self._hint_widget: NoMarkupStatic def compose(self) -> ComposeResult: @@ -141,6 +171,7 @@ class BrowserSignInScreen(OnboardingScreen): ) with Vertical(id="browser-sign-in-steps"): yield from self._compose_step_rows() + yield Static("", id="browser-sign-in-url") yield NoMarkupStatic("", id="browser-sign-in-hint") def _compose_step_rows(self) -> ComposeResult: @@ -159,6 +190,7 @@ class BrowserSignInScreen(OnboardingScreen): yield detail def on_mount(self) -> None: + self._url_widget = self.query_one("#browser-sign-in-url", Static) self._hint_widget = self.query_one("#browser-sign-in-hint", NoMarkupStatic) self.state = self._initial_state self.watch_state(self.state) @@ -187,6 +219,23 @@ class BrowserSignInScreen(OnboardingScreen): self._cancel_current_attempt() super().action_cancel() + def action_copy_url(self) -> None: + if self.state.variant == "success" or self.state.sign_in_url is None: + return + + if self._copy_sign_in_url(self.state.sign_in_url): + self.app.notify( + COPY_URL_SUCCESS_MESSAGE, + severity="information", + timeout=2, + markup=False, + ) + return + + self.state = replace( + self.state, show_sign_in_url_help=True, reveal_sign_in_url=True + ) + def _start_browser_sign_in(self) -> None: self._attempt_number += 1 attempt_number = self._attempt_number @@ -194,9 +243,11 @@ class BrowserSignInScreen(OnboardingScreen): self.state = BrowserSignInViewState( step=BrowserSignInStep.OPEN, message="Getting things ready...", - hint=PENDING_HINT, variant="pending", running=True, + sign_in_url=None, + show_sign_in_url_help=False, + reveal_sign_in_url=False, ) self._worker = self.run_worker( self._authenticate_in_browser(attempt_number), @@ -211,7 +262,7 @@ class BrowserSignInScreen(OnboardingScreen): try: browser_sign_in = self._browser_sign_in_factory() api_key = await browser_sign_in.authenticate( - lambda status: self._on_status(attempt_number, status) + lambda event: self._on_event(attempt_number, event) ) except asyncio.CancelledError: return @@ -255,18 +306,21 @@ class BrowserSignInScreen(OnboardingScreen): api_key, entrypoint_metadata=self._entrypoint_metadata, ) + self._cancel_sign_in_url_help_timer() if result != "completed": self._active_attempt_number = None self._worker = None self.app.exit(result) return - self.state = BrowserSignInViewState( + self.state = replace( + self.state, step=BrowserSignInStep.FINISH, message="Sign-in complete", - hint=SUCCESS_HINT, variant="success", running=True, + show_sign_in_url_help=False, + reveal_sign_in_url=False, ) if self._success_exit_delay > 0: await asyncio.sleep(self._success_exit_delay) @@ -274,32 +328,55 @@ class BrowserSignInScreen(OnboardingScreen): self._worker = None self.app.exit(result) + def _on_event(self, attempt_number: int, event: BrowserSignInEvent) -> None: + if isinstance(event, BrowserSignInAttemptStarted): + self._on_attempt_started(attempt_number, event) + return + + if isinstance(event, BrowserSignInStatusChanged): + self._on_status(attempt_number, event.status) + + def _on_attempt_started( + self, attempt_number: int, event: BrowserSignInAttemptStarted + ) -> None: + if not self._is_attempt_active(attempt_number): + return + + self._cancel_sign_in_url_help_timer() + self.state = replace( + self.state, + sign_in_url=event.sign_in_url, + show_sign_in_url_help=False, + reveal_sign_in_url=False, + ) + self._schedule_sign_in_url_help(attempt_number, event.sign_in_url) + def _on_status(self, attempt_number: int, status: BrowserSignInStatus) -> None: if not self._is_attempt_active(attempt_number): return match status: case BrowserSignInStatus.OPENING_BROWSER: - state = BrowserSignInViewState( + state = replace( + self.state, step=BrowserSignInStep.OPEN, message="Opening your browser...", - hint=PENDING_HINT, variant="pending", running=True, ) case BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN: - state = BrowserSignInViewState( + state = replace( + self.state, step=BrowserSignInStep.CONFIRM, message="Waiting for you to finish signing in...", - hint=PENDING_HINT, variant="pending", running=True, ) case BrowserSignInStatus.EXCHANGING | BrowserSignInStatus.COMPLETED: - state = BrowserSignInViewState( + state = replace( + self.state, step=BrowserSignInStep.FINISH, message="Finishing setup...", - hint=PENDING_HINT, variant="pending", running=True, ) @@ -313,6 +390,7 @@ class BrowserSignInScreen(OnboardingScreen): return self._hint_widget.update(state.hint) + self._url_widget.update(self._build_url_text(state)) for index, (widgets, (title, pending_detail, done_detail)) in enumerate( zip(self._step_widgets, STEP_DESCRIPTIONS, strict=True) @@ -369,7 +447,8 @@ class BrowserSignInScreen(OnboardingScreen): self.state.variant == "pending" and self.state.step == BrowserSignInStep.CONFIRM ): - self.watch_state(self.state) + widgets = self._step_widgets[self.state.step] + self._update_active_step_detail(widgets.detail, self.state) async def _close_browser_sign_in( self, browser_sign_in: BrowserSignInService | None @@ -387,23 +466,66 @@ class BrowserSignInScreen(OnboardingScreen): def _show_error(self, message: str) -> None: self._active_attempt_number = None self._worker = None - self.state = BrowserSignInViewState( - step=self.state.step, + self._cancel_sign_in_url_help_timer() + self.state = replace( + self.state, message=message, - hint=ERROR_HINT, variant="error", running=False, + show_sign_in_url_help=self.state.sign_in_url is not None, + reveal_sign_in_url=False, ) + def _build_url_text(self, state: BrowserSignInViewState) -> str: + if ( + state.variant == "success" + or state.sign_in_url is None + or not state.show_sign_in_url_help + ): + return "" + + help_text = ( + f"{escape(SIGN_IN_URL_HELP_PREFIX)}" + f"[@click='screen.copy_url']{escape(SIGN_IN_URL_COPY_LABEL)}[/]" + f"{escape(SIGN_IN_URL_HELP_SUFFIX)}" + ) + if not state.reveal_sign_in_url: + return help_text + + return ( + f"{help_text} {escape(SIGN_IN_URL_REVEAL_PREFIX)} " + f"{escape(state.sign_in_url)}" + ) + + def _schedule_sign_in_url_help(self, attempt_number: int, sign_in_url: str) -> None: + if self._sign_in_url_help_delay <= 0: + self._show_sign_in_url_help(attempt_number, sign_in_url) + return + + self._sign_in_url_help_timer = self.set_timer( + self._sign_in_url_help_delay, + lambda: self._show_sign_in_url_help(attempt_number, sign_in_url), + ) + + def _show_sign_in_url_help(self, attempt_number: int, sign_in_url: str) -> None: + self._sign_in_url_help_timer = None + if not self._is_attempt_active(attempt_number): + return + + if self.state.sign_in_url != sign_in_url: + return + + self.state = replace(self.state, show_sign_in_url_help=True) + + def _cancel_sign_in_url_help_timer(self) -> None: + if self._sign_in_url_help_timer is not None: + self._sign_in_url_help_timer.stop() + self._sign_in_url_help_timer = None + def _cancel_current_attempt(self) -> None: self._active_attempt_number = None - self.state = BrowserSignInViewState( - step=self.state.step, - message=self.state.message, - hint=self.state.hint, - variant=self.state.variant, - running=False, - ) + self._cancel_sign_in_url_help_timer() + self.state = replace(self.state, running=False) if self._worker is not None: self._worker.cancel() self._worker = None diff --git a/vibe/setup/trusted_folders/trust_folder_dialog.py b/vibe/setup/trusted_folders/trust_folder_dialog.py index bf6b51c..02d1bae 100644 --- a/vibe/setup/trusted_folders/trust_folder_dialog.py +++ b/vibe/setup/trusted_folders/trust_folder_dialog.py @@ -1,12 +1,19 @@ from __future__ import annotations +from enum import Enum from pathlib import Path from typing import Any, ClassVar from textual import events from textual.app import App, ComposeResult from textual.binding import Binding, BindingType -from textual.containers import Center, CenterMiddle, Horizontal, VerticalScroll +from textual.containers import ( + Center, + CenterMiddle, + Horizontal, + Vertical, + VerticalScroll, +) from textual.message import Message from textual.widgets import Static @@ -18,35 +25,72 @@ class TrustDialogQuitException(Exception): pass +class TrustDecision(Enum): + """User's choice from the trust dialog.""" + + TRUST_REPO = "trust_repo" + TRUST_CWD = "trust_cwd" + DECLINE = "decline" + + class TrustFolderDialog(CenterMiddle): can_focus = True can_focus_children = True + # Number keys 1-3 cover up to three options; extras no-op. BINDINGS: ClassVar[list[BindingType]] = [ Binding("left", "move_left", "Left", show=False), Binding("right", "move_right", "Right", show=False), Binding("enter", "select", "Select", show=False), - Binding("1", "select_1", "Yes", show=False), - Binding("y", "select_1", "Yes", show=False), - Binding("2", "select_2", "No", show=False), - Binding("n", "select_2", "No", show=False), + Binding("1", "select_index(0)", show=False), + Binding("2", "select_index(1)", show=False), + Binding("3", "select_index(2)", show=False), ] - class Trusted(Message): - pass - - class Untrusted(Message): - pass + class Decided(Message): + def __init__(self, decision: TrustDecision) -> None: + super().__init__() + self.decision = decision def __init__( - self, folder_path: Path, detected_files: list[str], **kwargs: Any + self, + cwd: Path, + repo_root: Path | None, + detected_files: list[str], + repo_detected_files: list[str] | None = None, + offer_repo_trust: bool = False, + repo_explicitly_untrusted: bool = False, + **kwargs: Any, ) -> None: super().__init__(**kwargs) - self.folder_path = folder_path + self.cwd = cwd + # Hide the repo line when it would duplicate cwd. + self.repo_root = repo_root if repo_root and repo_root != cwd else None + self.offer_repo_trust = offer_repo_trust and self.repo_root is not None + self.repo_explicitly_untrusted = ( + repo_explicitly_untrusted and self.repo_root is not None + ) self.detected_files = detected_files - self.selected_option = 0 + self.repo_detected_files = repo_detected_files or [] + self._options: list[tuple[TrustDecision, str]] = self._build_options() + # Default to the safest option (rightmost: Decline). + self.selected_option = len(self._options) - 1 self.option_widgets: list[Static] = [] + @property + def _title(self) -> str: + if self.offer_repo_trust: + return "Trust folder or repository?" + return "Trust this folder?" + + def _build_options(self) -> list[tuple[TrustDecision, str]]: + options: list[tuple[TrustDecision, str]] = [] + if self.offer_repo_trust: + options.append((TrustDecision.TRUST_REPO, "Trust full repo")) + options.append((TrustDecision.TRUST_CWD, "Trust folder")) + options.append((TrustDecision.DECLINE, "Don't trust")) + return options + def _compose_scroll_content(self) -> ComposeResult: why_content = ( "Files here can modify AI behavior. Malicious " @@ -61,26 +105,53 @@ class TrustFolderDialog(CenterMiddle): ) if self.detected_files: - yield NoMarkupStatic( - "Detected configuration files\n", classes="trust-dialog-section-header" - ) - file_list = "\n".join(f"\u2022 {f}" for f in self.detected_files) with Center(classes="trust-dialog-section-center"): - yield NoMarkupStatic( - file_list, - id="trust-dialog-files", - classes="trust-dialog-section-content", - ) + with Vertical(classes="trust-dialog-section-stack"): + yield NoMarkupStatic( + "Detected in current folder:", + classes="trust-dialog-section-title", + ) + yield NoMarkupStatic( + "\n".join(f"\u2022 {f}" for f in self.detected_files), + id="trust-dialog-files", + classes="trust-dialog-section-content trust-dialog-file-list", + ) + + if self.repo_detected_files: + with Center(classes="trust-dialog-section-center"): + with Vertical(classes="trust-dialog-section-stack"): + yield NoMarkupStatic( + "Detected in repository context:", + classes="trust-dialog-section-title", + ) + yield NoMarkupStatic( + "\n".join(f"\u2022 {f}" for f in self.repo_detected_files), + id="trust-dialog-files-repo", + classes="trust-dialog-section-content trust-dialog-file-list", + ) def compose(self) -> ComposeResult: with CenterMiddle(id="trust-dialog-container"): with CenterMiddle(id="trust-dialog"): - yield NoMarkupStatic("Trust this folder?", id="trust-dialog-title") + yield NoMarkupStatic(self._title, id="trust-dialog-title") + path_classes = "trust-dialog-path" + if self.repo_root is not None: + path_classes += " has-repo-root" yield NoMarkupStatic( - str(self.folder_path), - id="trust-dialog-path", - classes="trust-dialog-path", + str(self.cwd), id="trust-dialog-path", classes=path_classes ) + if self.repo_explicitly_untrusted: + yield NoMarkupStatic( + f"\u26a0 git repository {self.repo_root} is marked untrusted", + id="trust-dialog-repo-untrusted", + classes="trust-dialog-repo-untrusted", + ) + elif self.repo_root is not None: + yield NoMarkupStatic( + f"\u21b3 git repository: {self.repo_root}", + id="trust-dialog-repo-root", + classes="trust-dialog-repo-root", + ) with VerticalScroll(id="trust-dialog-content"): yield from self._compose_scroll_content() @@ -92,10 +163,9 @@ class TrustFolderDialog(CenterMiddle): ) with Horizontal(id="trust-options-container"): - options = ["Yes, trust this folder", "No, ignore config files"] - for idx, text in enumerate(options): + for idx, (_decision, label) in enumerate(self._options): widget = NoMarkupStatic( - f" {idx + 1}. {text}", classes="trust-option" + f" {idx + 1}. {label}", classes="trust-option" ) self.option_widgets.append(widget) yield widget @@ -111,25 +181,20 @@ class TrustFolderDialog(CenterMiddle): ) async def on_mount(self) -> None: - self.selected_option = 1 # Default to "No" self._update_options() self.focus() def _update_options(self) -> None: - options = ["Yes, trust this folder", "No, ignore config files"] - - if len(self.option_widgets) != len(options): + if len(self.option_widgets) != len(self._options): return - for idx, (text, widget) in enumerate( - zip(options, self.option_widgets, strict=True) + for idx, ((_, label), widget) in enumerate( + zip(self._options, self.option_widgets, strict=True) ): is_selected = idx == self.selected_option cursor = "› " if is_selected else " " - option_text = f"{cursor}{text}" - - widget.update(option_text) + widget.update(f"{cursor}{label}") widget.remove_class("trust-cursor-selected") widget.remove_class("trust-option-selected") @@ -140,30 +205,25 @@ class TrustFolderDialog(CenterMiddle): widget.add_class("trust-option-selected") def action_move_left(self) -> None: - self.selected_option = (self.selected_option - 1) % 2 + self.selected_option = (self.selected_option - 1) % len(self._options) self._update_options() def action_move_right(self) -> None: - self.selected_option = (self.selected_option + 1) % 2 + self.selected_option = (self.selected_option + 1) % len(self._options) self._update_options() def action_select(self) -> None: self._handle_selection(self.selected_option) - def action_select_1(self) -> None: - self.selected_option = 0 - self._handle_selection(0) - - def action_select_2(self) -> None: - self.selected_option = 1 - self._handle_selection(1) + def action_select_index(self, idx: int) -> None: + if not 0 <= idx < len(self._options): + return + self.selected_option = idx + self._handle_selection(idx) def _handle_selection(self, option: int) -> None: - match option: - case 0: - self.post_message(self.Trusted()) - case 1: - self.post_message(self.Untrusted()) + decision, _ = self._options[option] + self.post_message(self.Decided(decision)) def on_blur(self, event: events.Blur) -> None: self.call_after_refresh(self.focus) @@ -178,39 +238,69 @@ class TrustFolderApp(App): ] def __init__( - self, folder_path: Path, detected_files: list[str], **kwargs: Any + self, + cwd: Path, + repo_root: Path | None, + detected_files: list[str], + repo_detected_files: list[str] | None = None, + offer_repo_trust: bool = False, + repo_explicitly_untrusted: bool = False, + **kwargs: Any, ) -> None: super().__init__(**kwargs) - self.folder_path = folder_path + self.cwd = cwd + self.repo_root = repo_root + self.offer_repo_trust = offer_repo_trust + self.repo_explicitly_untrusted = repo_explicitly_untrusted self.detected_files = detected_files - self._result: bool | None = None + self.repo_detected_files = repo_detected_files or [] + self._result: TrustDecision | None = None self._quit_without_saving = False def on_mount(self) -> None: self.theme = "ansi-dark" def compose(self) -> ComposeResult: - yield TrustFolderDialog(self.folder_path, self.detected_files) + yield TrustFolderDialog( + self.cwd, + self.repo_root, + self.detected_files, + repo_detected_files=self.repo_detected_files, + offer_repo_trust=self.offer_repo_trust, + repo_explicitly_untrusted=self.repo_explicitly_untrusted, + ) def action_quit_without_saving(self) -> None: self._quit_without_saving = True self.exit() - def on_trust_folder_dialog_trusted(self, _: TrustFolderDialog.Trusted) -> None: - self._result = True + def on_trust_folder_dialog_decided( + self, message: TrustFolderDialog.Decided + ) -> None: + self._result = message.decision self.exit() - def on_trust_folder_dialog_untrusted(self, _: TrustFolderDialog.Untrusted) -> None: - self._result = False - self.exit() - - def run_trust_dialog(self) -> bool | None: + def run_trust_dialog(self) -> TrustDecision | None: self.run() if self._quit_without_saving: raise TrustDialogQuitException() return self._result -def ask_trust_folder(folder_path: Path, detected_files: list[str]) -> bool | None: - app = TrustFolderApp(folder_path, detected_files) +def ask_trust_folder( + cwd: Path, + repo_root: Path | None, + detected_files: list[str], + repo_detected_files: list[str] | None = None, + offer_repo_trust: bool = False, + repo_explicitly_untrusted: bool = False, +) -> TrustDecision | None: + app = TrustFolderApp( + cwd, + repo_root, + detected_files, + repo_detected_files=repo_detected_files, + offer_repo_trust=offer_repo_trust, + repo_explicitly_untrusted=repo_explicitly_untrusted, + ) return app.run_trust_dialog() diff --git a/vibe/setup/trusted_folders/trust_folder_dialog.tcss b/vibe/setup/trusted_folders/trust_folder_dialog.tcss index 23acc8e..48b8e29 100644 --- a/vibe/setup/trusted_folders/trust_folder_dialog.tcss +++ b/vibe/setup/trusted_folders/trust_folder_dialog.tcss @@ -3,18 +3,18 @@ Screen { background: transparent 80%; } +* { + scrollbar-size: 1 1; +} #trust-dialog { max-width: 70; - overflow-y: scroll; + overflow-y: auto; border: round $border-blurred; background: transparent; - height:auto; + height: auto; max-height: 1fr; - padding: 1; - padding-left:5; - padding-right:5; - scrollbar-size: 1 1; + padding: 1 5; } #trust-dialog-content { @@ -42,13 +42,32 @@ Screen { margin-bottom: 1; } -.trust-dialog-section-header { +#trust-dialog-path.has-repo-root { + margin-bottom: 0; +} + +.trust-dialog-repo-root { width: 100%; height: auto; - color: $foreground; + color: $text-muted; text-align: center; text-wrap: wrap; - padding: 0 2; + text-style: italic; + margin-bottom: 1; + + &:ansi { + text-style: italic dim; + } +} + +.trust-dialog-repo-untrusted { + width: 100%; + height: auto; + color: $warning; + text-align: center; + text-wrap: wrap; + text-style: italic; + margin-bottom: 1; } .trust-dialog-section-center { @@ -57,6 +76,11 @@ Screen { margin-bottom: 1; } +.trust-dialog-section-stack { + width: 100%; + height: auto; +} + .trust-dialog-section-content { width: auto; max-width: 100%; @@ -66,6 +90,24 @@ Screen { text-overflow: fold; } +.trust-dialog-section-title { + width: 100%; + height: auto; + color: $text-muted; + text-style: bold; + text-align: center; + margin-bottom: 0; + + &:ansi { + text-style: bold dim; + } +} + +.trust-dialog-file-list { + width: 100%; + text-align: center; +} + .trust-dialog-footer-warning { width: 100%; height: auto; diff --git a/vibe/whats_new.md b/vibe/whats_new.md index 2d4afde..2ccb013 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -1,5 +1,4 @@ -# What's new in v2.13.0 +# What's new in v2.14.0 -- **Custom compaction prompts**: Override the default `/compact` prompt by setting `compaction_prompt_id` and dropping a markdown file in `~/.vibe/prompts/` or `.vibe/prompts/`. -- **Safer programmatic mode**: `-p` no longer auto-approves tool calls by default — pass `--auto-approve` to restore the previous behavior. -- **Teleport Vibe Code Web**: `/teleport` now uses the new Vibe Code Web sessions. +- **Image attachments**: Drop an image into the chat input — or `@`-mention it — and send it to vision-capable models. +- **New read and edit tools**: `read` and `edit` replace `read_file` and `search_replace`. Your config has been migrated to these new tool names.