From ec7f3b25eaf47c447f4005561ae2ff32545d3dff Mon Sep 17 00:00:00 2001 From: Mathias Gesbert Date: Tue, 17 Feb 2026 16:23:28 +0100 Subject: [PATCH] v2.2.0 (#395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Quentin Torroba Co-authored-by: Clément Siriex Co-authored-by: Kim-Adeline Miguel Co-authored-by: Michel Thomazo Co-authored-by: Clément Drouin --- .pre-commit-config.yaml | 1 + .vscode/launch.json | 2 +- AGENTS.md | 7 + CHANGELOG.md | 29 + CONTRIBUTING.md | 16 + README.md | 12 +- distribution/zed/extension.toml | 14 +- docs/README.md | 1 + docs/acp-setup.md | 14 +- docs/proxy-setup.md | 36 + pyproject.toml | 51 +- tests/acp/conftest.py | 55 ++ tests/acp/test_acp.py | 12 + tests/acp/test_initialize.py | 12 +- tests/acp/test_list_sessions.py | 242 +++++++ tests/acp/test_load_session.py | 301 +++++++++ tests/acp/test_new_session.py | 8 +- tests/acp/test_proxy_setup_acp.py | 271 ++++++++ tests/acp/test_utils.py | 55 ++ .../test_ui_chat_autocompletion.py | 13 +- tests/backend/test_anthropic_adapter.py | 586 ++++++++++++++++ .../backend/test_vertex_anthropic_adapter.py | 591 ++++++++++++++++ tests/cli/test_clipboard.py | 15 +- tests/cli/test_commands.py | 54 ++ tests/conftest.py | 16 + tests/core/test_config_paths.py | 57 ++ tests/core/test_file_logging.py | 280 ++++++++ tests/core/test_proxy_setup.py | 304 +++++++++ tests/core/test_telemetry_send.py | 269 ++++++++ tests/core/test_trusted_folders.py | 52 +- tests/onboarding/test_ui_onboarding.py | 1 - tests/session/test_session_loader.py | 191 +++++- tests/skills/test_manager.py | 80 +++ ...t_snapshot_ask_user_question_collapsed.svg | 2 +- ...st_snapshot_ask_user_question_expanded.svg | 2 +- ...test_snapshot_shows_basic_conversation.svg | 2 +- ...izontal_scrolling_for_long_code_blocks.svg | 2 +- ...st_snapshot_cycle_to_accept_edits_mode.svg | 2 +- ...st_snapshot_cycle_to_auto_approve_mode.svg | 2 +- .../test_snapshot_cycle_to_plan_mode.svg | 2 +- .../test_snapshot_cycle_wraps_to_default.svg | 2 +- .../test_snapshot_default_mode.svg | 2 +- ...ot_proxy_setup_cancel_discards_changes.svg | 200 ++++++ ...pshot_proxy_setup_edit_existing_values.svg | 200 ++++++ ...est_snapshot_proxy_setup_initial_empty.svg | 204 ++++++ ...apshot_proxy_setup_initial_with_values.svg | 203 ++++++ .../test_snapshot_proxy_setup_save_error.svg | 201 ++++++ ...t_snapshot_proxy_setup_save_new_values.svg | 200 ++++++ ...ffered_reasoning_yields_before_content.svg | 2 +- ...t_snapshot_shows_interleaved_reasoning.svg | 2 +- .../test_snapshot_shows_reasoning_content.svg | 2 +- ...pshot_shows_reasoning_content_expanded.svg | 2 +- ...shot_shows_release_update_notification.svg | 2 +- ...napshot_shows_resumed_session_messages.svg | 2 +- .../test_snapshot_shows_no_plan_message.svg | 2 +- .../test_snapshot_shows_switch_message.svg | 2 +- .../test_snapshot_shows_upgrade_message.svg | 2 +- .../test_snapshot_shows_whats_new_message.svg | 2 +- tests/snapshots/conftest.py | 10 + .../snapshots/test_ui_snapshot_proxy_setup.py | 129 ++++ tests/test_agent_auto_compact.py | 11 +- tests/test_agent_observer_streaming.py | 30 +- tests/test_agent_stats.py | 9 +- tests/test_agent_tool_call.py | 52 +- tests/test_cli_programmatic_preload.py | 10 +- tests/test_message_merging.py | 47 ++ tests/test_middleware.py | 504 +++++++++++++- tests/test_reasoning_content.py | 1 + tests/tools/test_mcp.py | 68 +- uv.lock | 55 +- vibe/__init__.py | 2 +- vibe/acp/acp_agent_loop.py | 251 ++++++- vibe/acp/utils.py | 102 ++- vibe/cli/clipboard.py | 6 +- vibe/cli/commands.py | 10 +- vibe/cli/textual_ui/app.py | 122 +++- vibe/cli/textual_ui/app.tcss | 38 ++ .../cli/textual_ui/widgets/proxy_setup_app.py | 127 ++++ vibe/core/agent_loop.py | 370 +++++----- vibe/core/agents/models.py | 2 +- vibe/core/config.py | 4 + vibe/core/llm/backend/anthropic.py | 630 ++++++++++++++++++ vibe/core/llm/backend/base.py | 38 ++ vibe/core/llm/backend/generic.py | 90 +-- vibe/core/llm/backend/mistral.py | 7 +- vibe/core/llm/backend/vertex.py | 115 ++++ vibe/core/llm/format.py | 1 + vibe/core/llm/message_utils.py | 24 + vibe/core/middleware.py | 55 +- vibe/core/paths/config_paths.py | 12 +- vibe/core/paths/global_paths.py | 2 +- vibe/core/programmatic.py | 31 +- vibe/core/prompts/__init__.py | 1 + vibe/core/prompts/cli.md | 131 +++- vibe/core/prompts/explore.md | 50 ++ vibe/core/proxy_setup.py | 65 ++ vibe/core/session/session_loader.py | 67 +- vibe/core/skills/manager.py | 5 +- vibe/core/system_prompt.py | 4 +- .../{llm/backend => telemetry}/__init__.py | 0 vibe/core/telemetry/send.py | 185 +++++ vibe/core/tools/mcp.py | 69 +- vibe/core/trusted_folders.py | 17 +- vibe/core/types.py | 9 + vibe/core/utils.py | 56 +- vibe/setup/onboarding/screens/api_key.py | 9 +- vibe/whats_new.md | 10 +- 107 files changed, 8002 insertions(+), 535 deletions(-) create mode 100644 docs/proxy-setup.md create mode 100644 tests/acp/test_list_sessions.py create mode 100644 tests/acp/test_load_session.py create mode 100644 tests/acp/test_proxy_setup_acp.py create mode 100644 tests/acp/test_utils.py create mode 100644 tests/backend/test_anthropic_adapter.py create mode 100644 tests/backend/test_vertex_anthropic_adapter.py create mode 100644 tests/cli/test_commands.py create mode 100644 tests/core/test_config_paths.py create mode 100644 tests/core/test_file_logging.py create mode 100644 tests/core/test_proxy_setup.py create mode 100644 tests/core/test_telemetry_send.py create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_cancel_discards_changes.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_edit_existing_values.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_empty.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_with_values.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_error.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_new_values.svg create mode 100644 tests/snapshots/conftest.py create mode 100644 tests/snapshots/test_ui_snapshot_proxy_setup.py create mode 100644 tests/test_message_merging.py create mode 100644 vibe/cli/textual_ui/widgets/proxy_setup_app.py create mode 100644 vibe/core/llm/backend/anthropic.py create mode 100644 vibe/core/llm/backend/base.py create mode 100644 vibe/core/llm/backend/vertex.py create mode 100644 vibe/core/llm/message_utils.py create mode 100644 vibe/core/prompts/explore.md create mode 100644 vibe/core/proxy_setup.py rename vibe/core/{llm/backend => telemetry}/__init__.py (100%) create mode 100644 vibe/core/telemetry/send.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3e7c8e0..776db61 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,3 +32,4 @@ repos: hooks: - id: typos args: [--write-changes] + exclude: __snapshots__/.*\.svg$ diff --git a/.vscode/launch.json b/.vscode/launch.json index ce1d0d5..4be5748 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,5 +1,5 @@ { - "version": "2.1.0", + "version": "2.2.0", "configurations": [ { "name": "ACP Server", diff --git a/AGENTS.md b/AGENTS.md index 2e76438..e4f711e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,3 +133,10 @@ guidelines: - uv sync to install dependencies declared in pyproject.toml and uv.lock - uv run script.py to run a script within the uv environment - uv run pytest (or any other python tool) to run the tool within the uv environment + + - title: "Imports in Cursor (no Pylance)" + description: > + Cursor's built-in Pyright does not offer the "Add import" quick fix (Ctrl+.). To add a missing import: + - Use the workspace snippets: type the prefix (e.g. acpschema, acphelpers, vibetypes, vibeconfig) and accept the suggestion to insert the import line, then change the symbol name. + - Or ask Cursor: select the undefined symbol, then Cmd+K and request "Add the missing import for ". + - Or copy the import from an existing file in the repo (e.g. acp.schema, acp.helpers, vibe.core.*). diff --git a/CHANGELOG.md b/CHANGELOG.md index 5249d92..bd6722b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ 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.2.0] - 2026-02-17 + +### Added + +- Google Vertex AI support +- Telemetry: user interaction and tool usage events sent to datalake (configurable via `disable_telemetry`) +- Skill discovery from `.agents/skills/` (Agent Skills standard) in addition to `.vibe/skills/` +- ACP: `session/load` and `session/list` for loading and listing sessions +- New model behavior prompts (CLI and explore) +- Proxy Wizard (PoC) for CLI and for ACP +- Proxy setup documentation +- Documentation for JetBrains ACP registry + +### Changed + +- Trusted folders: presence of `.agents` is now considered trustable content +- Logging handling updated +- Pin `cryptography` to >=44.0.0,<=46.0.3; uv sync for cryptography + +### Fixed + +- Auto scroll when switching to input +- MCP stdio: redirect stderr to logger to avoid unwanted console output +- Align `pyproject.toml` minimum versions with `uv.lock` for pip installs +- Middleware injection: use standalone user messages instead of mutating flushed messages +- Revert cryptography 46.0.5 bump for compatibility +- Pin banner version in UI snapshot tests for stability + + ## [2.1.0] - 2026-02-11 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c4ccc53..c72a639 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,6 +72,22 @@ This section is for developers who want to set up the repository for local devel Pre-commit hooks will automatically run checks before each commit. +### Logging Configuration + +Logs are written to `~/.vibe/logs/vibe.log` by default. Control logging via environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `LOG_LEVEL` | Logging level (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`) | `WARNING` | +| `LOG_MAX_BYTES` | Max log file size in bytes before rotation | `10485760` (10 MB) | +| `DEBUG_MODE` | When `true`, forces `DEBUG` level | - | + +Example: + +```bash +LOG_LEVEL=DEBUG uv run vibe +``` + ### Running Tests Run all tests: diff --git a/README.md b/README.md index 19ec8cf..7d892c8 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ pip install mistral-vibe - [Custom Vibe Home Directory](#custom-vibe-home-directory) - [Editors/IDEs](#editorsides) - [Resources](#resources) +- [Data collection & usage](#data-collection--usage) - [License](#license) ## Features @@ -328,9 +329,10 @@ This skill helps analyze code quality and suggest improvements. Vibe discovers skills from multiple locations: -1. **Global skills directory**: `~/.vibe/skills/` -2. **Local project skills**: `.vibe/skills/` in your project -3. **Custom paths**: Configured in `config.toml` +1. **Custom paths**: Configured in `config.toml` via `skill_paths` +2. **Standard Agent Skills path** (project root, trusted folders only): `.agents/skills/` — [Agent Skills](https://agentskills.io) standard +3. **Local project skills** (project root, trusted folders only): `.vibe/skills/` in your project +4. **Global skills directory**: `~/.vibe/skills/` ```toml skill_paths = ["/path/to/custom/skills"] @@ -595,6 +597,10 @@ Mistral Vibe can be used in text editors and IDEs that support [Agent Client Pro - [CHANGELOG](CHANGELOG.md) - See what's new in each version - [CONTRIBUTING](CONTRIBUTING.md) - Guidelines for feature requests, feedback and bug reports +## Data collection & usage + +Use of Vibe is subject to our [Privacy Policy](https://legal.mistral.ai/terms/privacy-policy) and may include the collection and processing of data related to your use of the service, such as usage data, to operate, maintain, and improve Vibe. You can disable telemetry in your `config.toml` by setting `enable_telemetry = false`. + ## License Copyright 2025 Mistral AI diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index a483c9f..658dfcd 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.1.0" +version = "2.2.0" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,25 +11,25 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-darwin-aarch64-2.1.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-darwin-aarch64-2.2.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-darwin-x86_64-2.1.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-darwin-x86_64-2.2.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-linux-aarch64-2.1.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-linux-aarch64-2.2.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-linux-x86_64-2.1.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-linux-x86_64-2.2.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-windows-aarch64-2.1.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-windows-aarch64-2.2.0.zip" cmd = "./vibe-acp.exe" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.1.0/vibe-acp-windows-x86_64-2.1.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-windows-x86_64-2.2.0.zip" cmd = "./vibe-acp.exe" diff --git a/docs/README.md b/docs/README.md index 6805160..90c2b38 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,3 +5,4 @@ Welcome to the Mistral Vibe documentation! For basic setup, see the [main README ## Available Documentation - **[ACP Setup](acp-setup.md)** - Setup instructions for using Mistral Vibe with various editors and IDEs that support the Agent Client Protocol. +- **[Proxy Setup](proxy-setup.md)** - Configure proxy and SSL certificate settings for corporate networks or firewalls. diff --git a/docs/acp-setup.md b/docs/acp-setup.md index 06385c3..1277eb9 100644 --- a/docs/acp-setup.md +++ b/docs/acp-setup.md @@ -22,10 +22,20 @@ For usage in Zed, we recommend using the [Mistral Vibe Zed's extension](https:// } ``` -2. In the `New Thread` pane on the right, select the `vibe` agent and start the conversation. +1. In the `New Thread` pane on the right, select the `vibe` agent and start the conversation. ## JetBrains IDEs +For using Mistral Vibe in JetBrains IDEs, you'll need to have the [Jetbrains AI Assistant extension](https://plugins.jetbrains.com/plugin/22282-jetbrains-ai-assistant) installed + +### Version 2025.3 or later + +1. Open settings, then go to `Tools > AI Assistant > Agents`. Search for `Mistral Vibe`, click install + +2. Open AI Assistant. You should be able to select Mistral Vibe from the agent selector (if you're not authenticated yet, you will be prompted to do so). + +### Legacy method + 1. Add the following snippet to your JetBrains IDE acp.json ([documentation](https://www.jetbrains.com/help/ai-assistant/acp.html)): ```json @@ -38,7 +48,7 @@ For usage in Zed, we recommend using the [Mistral Vibe Zed's extension](https:// } ``` -2. In the AI Chat agent selector, select the new Mistral Vibe agent and start the conversation. +1. In the AI Chat agent selector, select the new Mistral Vibe agent and start the conversation. ## Neovim (using avante.nvim) diff --git a/docs/proxy-setup.md b/docs/proxy-setup.md new file mode 100644 index 0000000..3309d90 --- /dev/null +++ b/docs/proxy-setup.md @@ -0,0 +1,36 @@ +# Proxy Setup + +Mistral Vibe supports proxy configuration for environments that require network traffic to go through a proxy server. Proxy settings are shared between the CLI and ACP — configuring them in one will apply to both. + +## Using Mistral Vibe CLI + +Configure proxy settings through the interactive form: + +1. Type `/proxy-setup` and press Enter +2. Fill in the variables you need, then press **Enter** to save or **Escape** to cancel +3. **Restart the CLI** for changes to take effect + +## Through an ACP Client + +In ACP, variables must be set one at a time using the `/proxy-setup` command: + +```bash +/proxy-setup HTTP_PROXY http://proxy.example.com:8080 +``` + +Once all variables are configured, **restart the conversation** for changes to take effect. + +## Supported Environment Variables + +Mistral Vibe uses [httpx](https://www.python-httpx.org/environment_variables/) for HTTP requests and supports the same environment variables: + +| Variable | Description | +|----------|-------------| +| `HTTP_PROXY` | Proxy URL for HTTP requests | +| `HTTPS_PROXY` | Proxy URL for HTTPS requests | +| `ALL_PROXY` | Proxy URL for all requests (fallback when HTTP_PROXY/HTTPS_PROXY are not set) | +| `NO_PROXY` | Comma-separated list of hosts that should bypass the proxy | +| `SSL_CERT_FILE` | Path to a custom SSL certificate file | +| `SSL_CERT_DIR` | Path to a directory containing SSL certificates | + +These variables can also be set directly in your shell environment before launching the CLI (but will be overridden if set through the interactive form). diff --git a/pyproject.toml b/pyproject.toml index 72ca01d..1d61e8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.1.0" +version = "2.2.0" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" @@ -29,28 +29,30 @@ classifiers = [ dependencies = [ "agent-client-protocol==0.8.0", "anyio>=4.12.0", - "httpx>=0.28.1", - "mcp>=1.14.0", - "mistralai==1.9.11", - "pexpect>=4.9.0", - "packaging>=24.1", - "pydantic>=2.12.4", - "pydantic-settings>=2.12.0", - "pyyaml>=6.0.0", - "python-dotenv>=1.0.0", - "rich>=14.0.0", - "textual>=1.0.0", - "tomli-w>=1.2.0", - "watchfiles>=1.1.1", - "pyperclip>=1.11.0", - "textual-speedups>=0.2.1", - "tree-sitter>=0.25.2", - "tree-sitter-bash>=0.25.1", - "keyring>=25.6.0", - "cryptography>=44.0.0", - "zstandard>=0.25.0", + "cryptography>=44.0.0,<=46.0.3", "gitpython>=3.1.46", "giturlparse>=0.14.0", + "google-auth>=2.0.0", + "httpx>=0.28.1", + "keyring>=25.6.0", + "mcp>=1.14.0", + "mistralai==1.9.11", + "packaging>=24.1", + "pexpect>=4.9.0", + "pydantic>=2.12.4", + "pydantic-settings>=2.12.0", + "pyperclip>=1.11.0", + "python-dotenv>=1.0.0", + "pyyaml>=6.0.0", + "requests>=2.20.0", + "rich>=14.0.0", + "textual>=1.0.0", + "textual-speedups>=0.2.1", + "tomli-w>=1.2.0", + "tree-sitter>=0.25.2", + "tree-sitter-bash>=0.25.1", + "watchfiles>=1.1.1", + "zstandard>=0.25.0", ] [project.urls] @@ -59,7 +61,6 @@ Repository = "https://github.com/mistralai/mistral-vibe" Issues = "https://github.com/mistralai/mistral-vibe/issues" Documentation = "https://github.com/mistralai/mistral-vibe#readme" - [build-system] requires = ["hatchling", "hatch-vcs", "editables"] build-backend = "hatchling.build" @@ -83,19 +84,19 @@ required-version = ">=0.8.0" [dependency-groups] dev = [ + "debugpy>=1.8.19", "pre-commit>=4.2.0", "pyright>=1.1.403", "pytest>=8.3.5", "pytest-asyncio>=1.2.0", - "pytest-timeout>=2.4.0", "pytest-textual-snapshot>=1.1.0", + "pytest-timeout>=2.4.0", + "pytest-xdist>=3.8.0", "respx>=0.22.0", "ruff>=0.14.5", "twine>=5.0.0", "typos>=1.34.0", "vulture>=2.14", - "pytest-xdist>=3.8.0", - "debugpy>=1.8.19", ] build = ["pyinstaller>=6.17.0"] diff --git a/tests/acp/conftest.py b/tests/acp/conftest.py index a3a5bd7..5fd0eab 100644 --- a/tests/acp/conftest.py +++ b/tests/acp/conftest.py @@ -1,5 +1,8 @@ from __future__ import annotations +from datetime import datetime +import json +from pathlib import Path from unittest.mock import patch import pytest @@ -40,3 +43,55 @@ def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop: patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgent).start() return _create_acp_agent() + + +@pytest.fixture +def temp_session_dir(tmp_path: Path) -> Path: + session_dir = tmp_path / "sessions" + session_dir.mkdir() + return session_dir + + +@pytest.fixture +def create_test_session(): + """Create a test session with configurable messages and metadata. + + Supports both messages parameter (for load_session tests) and + end_time parameter (for list_sessions tests). + """ + + def _create_session( + session_dir: Path, + session_id: str, + cwd: str, + messages: list[dict] | None = None, + title: str | None = None, + end_time: str | None = None, + ) -> Path: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + session_folder = session_dir / f"session_{timestamp}_{session_id[:8]}" + session_folder.mkdir(exist_ok=True) + + if messages is None: + messages = [{"role": "user", "content": "Hello"}] + + messages_file = session_folder / "messages.jsonl" + with messages_file.open("w", encoding="utf-8") as f: + for msg in messages: + f.write(json.dumps(msg) + "\n") + + metadata = { + "session_id": session_id, + "start_time": "2024-01-01T12:00:00Z", + "end_time": end_time or "2024-01-01T12:05:00Z", + "environment": {"working_directory": cwd}, + "title": title, + } + + metadata_file = session_folder / "meta.json" + with metadata_file.open("w", encoding="utf-8") as f: + json.dump(metadata, f) + + return session_folder + + return _create_session diff --git a/tests/acp/test_acp.py b/tests/acp/test_acp.py index 304d647..026e92d 100644 --- a/tests/acp/test_acp.py +++ b/tests/acp/test_acp.py @@ -451,6 +451,18 @@ class TestSessionUpdates: ), ), ) + + commands_response_text = await read_response(process) + assert commands_response_text is not None + commands_response = UpdateJsonRpcNotification.model_validate( + json.loads(commands_response_text) + ) + assert commands_response.params is not None + assert ( + commands_response.params.update.session_update + == "available_commands_update" + ) + user_response_text = await read_response(process) assert user_response_text is not None user_response = UpdateJsonRpcNotification.model_validate( diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index d5a7d51..5f62f6e 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -6,6 +6,8 @@ from acp.schema import ( ClientCapabilities, Implementation, PromptCapabilities, + SessionCapabilities, + SessionListCapabilities, ) import pytest @@ -19,13 +21,14 @@ class TestACPInitialize: assert response.protocol_version == PROTOCOL_VERSION assert response.agent_capabilities == AgentCapabilities( - load_session=False, + load_session=True, prompt_capabilities=PromptCapabilities( audio=False, embedded_context=True, image=False ), + session_capabilities=SessionCapabilities(list=SessionListCapabilities()), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.1.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.2.0" ) assert response.auth_methods == [] @@ -42,13 +45,14 @@ class TestACPInitialize: assert response.protocol_version == PROTOCOL_VERSION assert response.agent_capabilities == AgentCapabilities( - load_session=False, + load_session=True, prompt_capabilities=PromptCapabilities( audio=False, embedded_context=True, image=False ), + session_capabilities=SessionCapabilities(list=SessionListCapabilities()), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.1.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.2.0" ) assert response.auth_methods is not None diff --git a/tests/acp/test_list_sessions.py b/tests/acp/test_list_sessions.py new file mode 100644 index 0000000..2621076 --- /dev/null +++ b/tests/acp/test_list_sessions.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from tests.acp.conftest import _create_acp_agent +from vibe.core.config import MissingAPIKeyError, SessionLoggingConfig + + +class TestListSessions: + @pytest.mark.asyncio + async def test_list_sessions_empty(self, temp_session_dir: Path) -> None: + acp_agent = _create_acp_agent() + + session_config = SessionLoggingConfig( + save_dir=str(temp_session_dir), session_prefix="session", enabled=True + ) + + with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load: + mock_config = mock_load.return_value + mock_config.session_logging = session_config + + response = await acp_agent.list_sessions() + + assert response.sessions == [] + + @pytest.mark.asyncio + async def test_list_sessions_returns_all_sessions( + self, temp_session_dir: Path, create_test_session + ) -> None: + acp_agent = _create_acp_agent() + + create_test_session( + temp_session_dir, + "aaaaaaaa-1111", + "/home/user/project1", + title="First session", + end_time="2024-01-01T12:00:00Z", + ) + create_test_session( + temp_session_dir, + "bbbbbbbb-2222", + "/home/user/project2", + title="Second session", + end_time="2024-01-01T13:00:00Z", + ) + + session_config = SessionLoggingConfig( + save_dir=str(temp_session_dir), session_prefix="session", enabled=True + ) + + with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load: + mock_config = mock_load.return_value + mock_config.session_logging = session_config + + response = await acp_agent.list_sessions() + + assert len(response.sessions) == 2 + session_ids = {s.session_id for s in response.sessions} + assert "aaaaaaaa-1111" in session_ids + assert "bbbbbbbb-2222" in session_ids + + @pytest.mark.asyncio + async def test_list_sessions_filters_by_cwd( + self, temp_session_dir: Path, create_test_session + ) -> None: + acp_agent = _create_acp_agent() + + create_test_session( + temp_session_dir, + "aaaaaaaa-proj1", + "/home/user/project1", + title="Project 1 session", + ) + create_test_session( + temp_session_dir, + "bbbbbbbb-proj2", + "/home/user/project2", + title="Project 2 session", + ) + create_test_session( + temp_session_dir, + "cccccccc-proj1", + "/home/user/project1", + title="Another Project 1 session", + ) + + session_config = SessionLoggingConfig( + save_dir=str(temp_session_dir), session_prefix="session", enabled=True + ) + + with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load: + mock_config = mock_load.return_value + mock_config.session_logging = session_config + + response = await acp_agent.list_sessions(cwd="/home/user/project1") + + assert len(response.sessions) == 2 + for session in response.sessions: + assert session.cwd == "/home/user/project1" + + @pytest.mark.asyncio + async def test_list_sessions_sorted_by_updated_at( + self, temp_session_dir: Path, create_test_session + ) -> None: + acp_agent = _create_acp_agent() + + create_test_session( + temp_session_dir, + "oldest-s", + "/home/user/project", + title="Oldest", + end_time="2024-01-01T10:00:00Z", + ) + create_test_session( + temp_session_dir, + "newest-s", + "/home/user/project", + title="Newest", + end_time="2024-01-01T14:00:00Z", + ) + create_test_session( + temp_session_dir, + "middle-s", + "/home/user/project", + title="Middle", + end_time="2024-01-01T12:00:00Z", + ) + + session_config = SessionLoggingConfig( + save_dir=str(temp_session_dir), session_prefix="session", enabled=True + ) + + with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load: + mock_config = mock_load.return_value + mock_config.session_logging = session_config + + response = await acp_agent.list_sessions() + + assert len(response.sessions) == 3 + + assert response.sessions[0].title == "Newest" + assert response.sessions[1].title == "Middle" + assert response.sessions[2].title == "Oldest" + + @pytest.mark.asyncio + async def test_list_sessions_includes_session_info_fields( + self, temp_session_dir: Path, create_test_session + ) -> None: + acp_agent = _create_acp_agent() + + create_test_session( + temp_session_dir, + "test-session-123", + "/home/user/project", + title="Test Session Title", + end_time="2024-01-15T10:30:00Z", + ) + + session_config = SessionLoggingConfig( + save_dir=str(temp_session_dir), session_prefix="session", enabled=True + ) + + with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load: + mock_config = mock_load.return_value + mock_config.session_logging = session_config + + response = await acp_agent.list_sessions() + + assert len(response.sessions) == 1 + session = response.sessions[0] + assert session.session_id == "test-session-123" + assert session.cwd == "/home/user/project" + assert session.title == "Test Session Title" + # updated_at is normalized to UTC + assert session.updated_at is not None + assert session.updated_at.endswith("+00:00") + + @pytest.mark.asyncio + async def test_list_sessions_skips_invalid_sessions( + self, temp_session_dir: Path, create_test_session + ) -> None: + acp_agent = _create_acp_agent() + + create_test_session( + temp_session_dir, "valid-se", "/home/user/project", title="Valid Session" + ) + + invalid_session = temp_session_dir / "session_20240101_120000_invalid1" + invalid_session.mkdir() + (invalid_session / "meta.json").write_text('{"session_id": "invalid"}') + + no_id_session = temp_session_dir / "session_20240101_120001_noid0000" + no_id_session.mkdir() + (no_id_session / "messages.jsonl").write_text( + '{"role": "user", "content": "Hello"}\n' + ) + (no_id_session / "meta.json").write_text( + '{"environment": {"working_directory": "/test"}}' + ) + + session_config = SessionLoggingConfig( + save_dir=str(temp_session_dir), session_prefix="session", enabled=True + ) + + with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load: + mock_config = mock_load.return_value + mock_config.session_logging = session_config + + response = await acp_agent.list_sessions() + + assert len(response.sessions) == 1 + assert response.sessions[0].session_id == "valid-se" + + @pytest.mark.asyncio + async def test_list_sessions_nonexistent_save_dir(self) -> None: + acp_agent = _create_acp_agent() + + session_config = SessionLoggingConfig( + save_dir="/nonexistent/path", session_prefix="session", enabled=True + ) + + with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load: + mock_config = mock_load.return_value + mock_config.session_logging = session_config + + response = await acp_agent.list_sessions() + + assert response.sessions == [] + + @pytest.mark.asyncio + async def test_list_sessions_without_api_key(self) -> None: + acp_agent = _create_acp_agent() + + with patch("vibe.acp.acp_agent_loop.VibeConfig.load") as mock_load: + mock_load.side_effect = MissingAPIKeyError("api_key", "mistral") + + response = await acp_agent.list_sessions() + + assert response.sessions == [] diff --git a/tests/acp/test_load_session.py b/tests/acp/test_load_session.py new file mode 100644 index 0000000..de0de0f --- /dev/null +++ b/tests/acp/test_load_session.py @@ -0,0 +1,301 @@ +from __future__ import annotations + +from pathlib import Path + +from acp import RequestError +from acp.schema import ( + AgentMessageChunk, + AgentThoughtChunk, + ToolCallProgress, + ToolCallStart, + UserMessageChunk, +) +import pytest + +from tests.conftest import build_test_vibe_config +from tests.stubs.fake_backend import FakeBackend +from tests.stubs.fake_client import FakeClient +from vibe.acp.acp_agent_loop import VibeAcpAgentLoop +from vibe.core.agent_loop import AgentLoop +from vibe.core.agents.models import BuiltinAgentName +from vibe.core.config import ModelConfig, SessionLoggingConfig +from vibe.core.types import Role + + +@pytest.fixture +def acp_agent_with_session_config( + backend: FakeBackend, temp_session_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> tuple[VibeAcpAgentLoop, FakeClient]: + session_config = SessionLoggingConfig( + save_dir=str(temp_session_dir), session_prefix="session", enabled=True + ) + config = build_test_vibe_config( + active_model="devstral-latest", + models=[ + ModelConfig( + name="devstral-latest", provider="mistral", alias="devstral-latest" + ) + ], + session_logging=session_config, + ) + + class PatchedAgentLoop(AgentLoop): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **{**kwargs, "backend": backend}) + self._base_config = config + self.agent_manager.invalidate_config() + + monkeypatch.setattr("vibe.acp.acp_agent_loop.AgentLoop", PatchedAgentLoop) + monkeypatch.setattr(VibeAcpAgentLoop, "_load_config", lambda self: config) + + vibe_acp_agent = VibeAcpAgentLoop() + client = FakeClient() + vibe_acp_agent.on_connect(client) + client.on_connect(vibe_acp_agent) + + return vibe_acp_agent, client + + +class TestLoadSession: + @pytest.mark.asyncio + async def test_load_session_returns_response_with_models_and_modes( + self, + acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], + temp_session_dir: Path, + create_test_session, + ) -> None: + acp_agent, _client = acp_agent_with_session_config + + session_id = "test-sess-12345678" + cwd = str(Path.cwd()) + create_test_session(temp_session_dir, session_id, cwd) + + response = await acp_agent.load_session( + cwd=cwd, mcp_servers=[], session_id=session_id + ) + + assert response is not None + assert response.models is not None + assert response.models.current_model_id == "devstral-latest" + assert response.modes is not None + assert response.modes.current_mode_id == BuiltinAgentName.DEFAULT + + @pytest.mark.asyncio + async def test_load_session_registers_session_with_original_id( + self, + acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], + temp_session_dir: Path, + create_test_session, + ) -> None: + acp_agent, _client = acp_agent_with_session_config + + session_id = "orig-id-12345678" + cwd = str(Path.cwd()) + create_test_session(temp_session_dir, session_id, cwd) + + await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id) + + assert session_id in acp_agent.sessions + assert acp_agent.sessions[session_id].id == session_id + + @pytest.mark.asyncio + async def test_load_session_injects_messages_into_agent_loop( + self, + acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], + temp_session_dir: Path, + create_test_session, + ) -> None: + acp_agent, _client = acp_agent_with_session_config + + session_id = "msg-test-12345678" + cwd = str(Path.cwd()) + messages = [ + {"role": "system", "content": "You are helpful"}, + {"role": "user", "content": "First question"}, + {"role": "assistant", "content": "First answer"}, + {"role": "user", "content": "Second question"}, + {"role": "assistant", "content": "Second answer"}, + ] + create_test_session(temp_session_dir, session_id, cwd, messages=messages) + + await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id) + + session = acp_agent.sessions[session_id] + + non_system = [m for m in session.agent_loop.messages if m.role != Role.system] + assert len(non_system) == 4 + + @pytest.mark.asyncio + async def test_load_session_replays_user_messages( + self, + acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], + temp_session_dir: Path, + create_test_session, + ) -> None: + acp_agent, client = acp_agent_with_session_config + + session_id = "replay-usr-123456" + cwd = str(Path.cwd()) + messages = [{"role": "user", "content": "Hello world"}] + create_test_session(temp_session_dir, session_id, cwd, messages=messages) + + await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id) + + user_updates = [ + u for u in client._session_updates if isinstance(u.update, UserMessageChunk) + ] + assert len(user_updates) == 1 + assert user_updates[0].update.content.text == "Hello world" + + @pytest.mark.asyncio + async def test_load_session_replays_assistant_messages( + self, + acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], + temp_session_dir: Path, + create_test_session, + ) -> None: + acp_agent, client = acp_agent_with_session_config + + session_id = "replay-ast-123456" + cwd = str(Path.cwd()) + messages = [ + {"role": "user", "content": "Hi"}, + {"role": "assistant", "content": "Hello! How can I help?"}, + ] + create_test_session(temp_session_dir, session_id, cwd, messages=messages) + + await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id) + + agent_updates = [ + u + for u in client._session_updates + if isinstance(u.update, AgentMessageChunk) + ] + assert len(agent_updates) == 1 + assert agent_updates[0].update.content.text == "Hello! How can I help?" + + @pytest.mark.asyncio + async def test_load_session_replays_tool_calls( + self, + acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], + temp_session_dir: Path, + create_test_session, + ) -> None: + acp_agent, client = acp_agent_with_session_config + + session_id = "replay-tool-12345" + cwd = str(Path.cwd()) + messages = [ + {"role": "user", "content": "Read the file"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_123", + "type": "function", + "function": { + "name": "read_file", + "arguments": '{"path": "/tmp/test.txt"}', + }, + } + ], + }, + {"role": "tool", "tool_call_id": "call_123", "content": "file contents"}, + ] + create_test_session(temp_session_dir, session_id, cwd, messages=messages) + + await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id) + + tool_call_starts = [ + 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.tool_call_id == "call_123" + + tool_results = [ + u for u in client._session_updates if isinstance(u.update, ToolCallProgress) + ] + assert len(tool_results) == 1 + assert tool_results[0].update.tool_call_id == "call_123" + assert tool_results[0].update.status == "completed" + + @pytest.mark.asyncio + async def test_load_session_replays_reasoning_content( + self, + acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], + temp_session_dir: Path, + create_test_session, + ) -> None: + acp_agent, client = acp_agent_with_session_config + + session_id = "replay-reason-123" + cwd = str(Path.cwd()) + messages = [ + {"role": "user", "content": "Think about this"}, + { + "role": "assistant", + "content": "Here is my answer", + "reasoning_content": "Let me think step by step...", + }, + ] + create_test_session(temp_session_dir, session_id, cwd, messages=messages) + + await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id) + + thought_updates = [ + u + for u in client._session_updates + if isinstance(u.update, AgentThoughtChunk) + ] + assert len(thought_updates) == 1 + assert thought_updates[0].update.content.text == "Let me think step by step..." + + @pytest.mark.asyncio + async def test_load_session_not_found_raises_error( + self, acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient] + ) -> None: + acp_agent, _client = acp_agent_with_session_config + + with pytest.raises(RequestError): + await acp_agent.load_session( + cwd=str(Path.cwd()), mcp_servers=[], session_id="nonexistent-session" + ) + + @pytest.mark.asyncio + async def test_load_session_replays_full_conversation( + self, + acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], + temp_session_dir: Path, + create_test_session, + ) -> None: + acp_agent, client = acp_agent_with_session_config + + session_id = "full-conv-1234567" + cwd = str(Path.cwd()) + messages = [ + {"role": "user", "content": "First message"}, + {"role": "assistant", "content": "First response"}, + {"role": "user", "content": "Second message"}, + {"role": "assistant", "content": "Second response"}, + ] + create_test_session(temp_session_dir, session_id, cwd, messages=messages) + + await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id) + + user_updates = [ + u for u in client._session_updates if isinstance(u.update, UserMessageChunk) + ] + agent_updates = [ + u + for u in client._session_updates + if isinstance(u.update, AgentMessageChunk) + ] + + assert len(user_updates) == 2 + assert len(agent_updates) == 2 + assert user_updates[0].update.content.text == "First message" + assert user_updates[1].update.content.text == "Second message" + assert agent_updates[0].update.content.text == "First response" + assert agent_updates[1].update.content.text == "Second response" diff --git a/tests/acp/test_new_session.py b/tests/acp/test_new_session.py index 7791815..6788c98 100644 --- a/tests/acp/test_new_session.py +++ b/tests/acp/test_new_session.py @@ -41,12 +41,18 @@ def acp_agent_loop(backend) -> VibeAcpAgentLoop: class TestACPNewSession: @pytest.mark.asyncio async def test_new_session_response_structure( - self, acp_agent_loop: VibeAcpAgentLoop + self, acp_agent_loop: VibeAcpAgentLoop, telemetry_events: list[dict] ) -> None: session_response = await acp_agent_loop.new_session( cwd=str(Path.cwd()), mcp_servers=[] ) + new_session_events = [ + e for e in telemetry_events if e.get("event_name") == "vibe/new_session" + ] + assert len(new_session_events) == 1 + assert new_session_events[0]["properties"]["entrypoint"] == "acp" + assert session_response.session_id is not None acp_session = next( ( diff --git a/tests/acp/test_proxy_setup_acp.py b/tests/acp/test_proxy_setup_acp.py new file mode 100644 index 0000000..2eb743e --- /dev/null +++ b/tests/acp/test_proxy_setup_acp.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from acp.schema import AgentMessageChunk, AvailableCommandsUpdate, TextContentBlock +import pytest + +from tests.acp.conftest import _create_acp_agent +from tests.conftest import build_test_vibe_config +from tests.stubs.fake_client import FakeClient +from vibe.acp.acp_agent_loop import VibeAcpAgentLoop +from vibe.core.agent_loop import AgentLoop + + +@pytest.fixture +def acp_agent_loop(backend) -> VibeAcpAgentLoop: + config = build_test_vibe_config() + + class PatchedAgentLoop(AgentLoop): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **{**kwargs, "backend": backend}) + self._base_config = config + self.agent_manager.invalidate_config() + + patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start() + + return _create_acp_agent() + + +def _get_fake_client(acp_agent_loop: VibeAcpAgentLoop) -> FakeClient: + assert isinstance(acp_agent_loop.client, FakeClient) + return acp_agent_loop.client + + +class TestAvailableCommandsUpdate: + @pytest.mark.asyncio + async def test_available_commands_sent_on_new_session( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + import asyncio + + await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + await asyncio.sleep(0) + + updates = _get_fake_client(acp_agent_loop)._session_updates + available_commands_updates = [ + u for u in updates if isinstance(u.update, AvailableCommandsUpdate) + ] + + assert len(available_commands_updates) == 1 + update = available_commands_updates[0].update + assert len(update.available_commands) == 1 + assert update.available_commands[0].name == "proxy-setup" + assert "proxy" in update.available_commands[0].description.lower() + + +class TestProxySetupCommand: + @pytest.mark.asyncio + async def test_proxy_setup_shows_help_when_no_args( + self, + acp_agent_loop: VibeAcpAgentLoop, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + env_file = tmp_path / ".env" + + class FakeGlobalEnvFile: + path = env_file + + monkeypatch.setattr( + "vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile() + ) + + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + session_id = session_response.session_id + + _get_fake_client(acp_agent_loop)._session_updates.clear() + + response = await acp_agent_loop.prompt( + prompt=[TextContentBlock(type="text", text="/proxy-setup")], + session_id=session_id, + ) + + assert response.stop_reason == "end_turn" + + updates = _get_fake_client(acp_agent_loop)._session_updates + message_updates = [ + u for u in updates if isinstance(u.update, AgentMessageChunk) + ] + + assert len(message_updates) == 1 + content = message_updates[0].update.content.text + assert "## Proxy Configuration" in content + assert "HTTP_PROXY" in content + + @pytest.mark.asyncio + async def test_proxy_setup_sets_value( + self, + acp_agent_loop: VibeAcpAgentLoop, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + env_file = tmp_path / ".env" + + class FakeGlobalEnvFile: + path = env_file + + monkeypatch.setattr( + "vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile() + ) + + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + session_id = session_response.session_id + + _get_fake_client(acp_agent_loop)._session_updates.clear() + + response = await acp_agent_loop.prompt( + prompt=[ + TextContentBlock( + type="text", text="/proxy-setup HTTP_PROXY http://localhost:8080" + ) + ], + session_id=session_id, + ) + + assert response.stop_reason == "end_turn" + + updates = _get_fake_client(acp_agent_loop)._session_updates + message_updates = [ + u for u in updates if isinstance(u.update, AgentMessageChunk) + ] + + assert len(message_updates) == 1 + content = message_updates[0].update.content.text + assert "HTTP_PROXY" in content + assert "http://localhost:8080" in content + + assert env_file.exists() + env_content = env_file.read_text() + assert "HTTP_PROXY" in env_content + assert "http://localhost:8080" in env_content + + @pytest.mark.asyncio + async def test_proxy_setup_unsets_value( + self, + acp_agent_loop: VibeAcpAgentLoop, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + env_file = tmp_path / ".env" + env_file.write_text("HTTP_PROXY=http://old-proxy.com\n") + + class FakeGlobalEnvFile: + path = env_file + + monkeypatch.setattr( + "vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile() + ) + + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + session_id = session_response.session_id + + _get_fake_client(acp_agent_loop)._session_updates.clear() + + response = await acp_agent_loop.prompt( + prompt=[TextContentBlock(type="text", text="/proxy-setup HTTP_PROXY")], + session_id=session_id, + ) + + assert response.stop_reason == "end_turn" + + updates = _get_fake_client(acp_agent_loop)._session_updates + message_updates = [ + u for u in updates if isinstance(u.update, AgentMessageChunk) + ] + + assert len(message_updates) == 1 + content = message_updates[0].update.content.text + assert "Removed" in content + assert "HTTP_PROXY" in content + + env_content = env_file.read_text() + assert "HTTP_PROXY" not in env_content + + @pytest.mark.asyncio + async def test_proxy_setup_invalid_key_returns_error( + self, + acp_agent_loop: VibeAcpAgentLoop, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + env_file = tmp_path / ".env" + + class FakeGlobalEnvFile: + path = env_file + + monkeypatch.setattr( + "vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile() + ) + + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + session_id = session_response.session_id + + _get_fake_client(acp_agent_loop)._session_updates.clear() + + response = await acp_agent_loop.prompt( + prompt=[ + TextContentBlock(type="text", text="/proxy-setup INVALID_KEY value") + ], + session_id=session_id, + ) + + assert response.stop_reason == "end_turn" + + updates = _get_fake_client(acp_agent_loop)._session_updates + message_updates = [ + u for u in updates if isinstance(u.update, AgentMessageChunk) + ] + + assert len(message_updates) == 1 + content = message_updates[0].update.content.text + assert "Error" in content + assert "Unknown key" in content + + @pytest.mark.asyncio + async def test_proxy_setup_case_insensitive( + self, + acp_agent_loop: VibeAcpAgentLoop, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + env_file = tmp_path / ".env" + + class FakeGlobalEnvFile: + path = env_file + + monkeypatch.setattr( + "vibe.core.proxy_setup.GLOBAL_ENV_FILE", FakeGlobalEnvFile() + ) + + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + session_id = session_response.session_id + + _get_fake_client(acp_agent_loop)._session_updates.clear() + + response = await acp_agent_loop.prompt( + prompt=[ + TextContentBlock( + type="text", text="/PROXY-SETUP http_proxy http://localhost:8080" + ) + ], + session_id=session_id, + ) + + assert response.stop_reason == "end_turn" + + assert env_file.exists() + env_content = env_file.read_text() + assert "HTTP_PROXY" in env_content diff --git a/tests/acp/test_utils.py b/tests/acp/test_utils.py new file mode 100644 index 0000000..25cf55a --- /dev/null +++ b/tests/acp/test_utils.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from vibe.acp.utils import get_proxy_help_text +from vibe.core.paths.global_paths import GLOBAL_ENV_FILE +from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS + + +def _write_env_file(content: str) -> None: + GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True) + GLOBAL_ENV_FILE.path.write_text(content, encoding="utf-8") + + +class TestGetProxyHelpText: + def test_returns_string(self) -> None: + result = get_proxy_help_text() + + assert isinstance(result, str) + + def test_includes_proxy_configuration_header(self) -> None: + result = get_proxy_help_text() + + assert "## Proxy Configuration" in result + + def test_includes_usage_section(self) -> None: + result = get_proxy_help_text() + + assert "### Usage:" in result + assert "/proxy-setup" in result + + def test_includes_all_supported_variables(self) -> None: + result = get_proxy_help_text() + + for key in SUPPORTED_PROXY_VARS: + assert f"`{key}`" in result + + def test_shows_none_configured_when_no_settings(self) -> None: + result = get_proxy_help_text() + + assert "(none configured)" in result + + def test_shows_current_settings_when_configured(self) -> None: + _write_env_file("HTTP_PROXY=http://proxy:8080\n") + + result = get_proxy_help_text() + + assert "HTTP_PROXY=http://proxy:8080" in result + assert "(none configured)" not in result + + def test_shows_only_set_values(self) -> None: + _write_env_file("HTTP_PROXY=http://proxy:8080\n") + + result = get_proxy_help_text() + + assert "HTTP_PROXY=http://proxy:8080" in result + assert "HTTPS_PROXY=" not in result diff --git a/tests/autocompletion/test_ui_chat_autocompletion.py b/tests/autocompletion/test_ui_chat_autocompletion.py index be779c9..adf12b9 100644 --- a/tests/autocompletion/test_ui_chat_autocompletion.py +++ b/tests/autocompletion/test_ui_chat_autocompletion.py @@ -100,7 +100,7 @@ async def test_arrow_navigation_cycles_through_suggestions(vibe_app: VibeApp) -> @pytest.mark.asyncio async def test_pressing_enter_submits_selected_command_and_hides_popup( - vibe_app: VibeApp, + vibe_app: VibeApp, telemetry_events: list[dict] ) -> None: async with vibe_app.run_test() as pilot: chat_input = vibe_app.query_one(ChatInputContainer) @@ -115,6 +115,17 @@ async def test_pressing_enter_submits_selected_command_and_hides_popup( message_content = message.query_one(Markdown) assert "Show help message" in message_content.source + slash_used = [ + e + for e in telemetry_events + if e.get("event_name") == "vibe/slash_command_used" + ] + assert any( + e.get("properties", {}).get("command") == "help" + and e.get("properties", {}).get("command_type") == "builtin" + for e in slash_used + ) + @pytest.fixture() def file_tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: diff --git a/tests/backend/test_anthropic_adapter.py b/tests/backend/test_anthropic_adapter.py new file mode 100644 index 0000000..14ddf8f --- /dev/null +++ b/tests/backend/test_anthropic_adapter.py @@ -0,0 +1,586 @@ +from __future__ import annotations + +import json + +import pytest + +from vibe.core.config import ProviderConfig +from vibe.core.llm.backend.anthropic import AnthropicAdapter, AnthropicMapper +from vibe.core.types import ( + AvailableFunction, + AvailableTool, + FunctionCall, + LLMMessage, + Role, + ToolCall, +) + + +@pytest.fixture +def mapper(): + return AnthropicMapper() + + +@pytest.fixture +def adapter(): + return AnthropicAdapter() + + +@pytest.fixture +def provider(): + return ProviderConfig( + name="anthropic", + api_base="https://api.anthropic.com", + api_key_env_var="ANTHROPIC_API_KEY", + api_style="anthropic", + ) + + +class TestMapperPrepareMessages: + def test_system_extracted(self, mapper): + messages = [ + LLMMessage(role=Role.system, content="You are helpful."), + LLMMessage(role=Role.user, content="Hi"), + ] + system, converted = mapper.prepare_messages(messages) + assert system == "You are helpful." + assert len(converted) == 1 + assert converted[0]["role"] == "user" + + def test_user_message(self, mapper): + messages = [LLMMessage(role=Role.user, content="Hello")] + _, converted = mapper.prepare_messages(messages) + assert converted[0]["content"] == [{"type": "text", "text": "Hello"}] + + def test_assistant_text(self, mapper): + messages = [LLMMessage(role=Role.assistant, content="Sure")] + _, converted = mapper.prepare_messages(messages) + assert converted[0]["role"] == "assistant" + content = converted[0]["content"] + assert any(b.get("type") == "text" and b.get("text") == "Sure" for b in content) + + def test_assistant_with_reasoning_content_and_signature(self, mapper): + messages = [ + LLMMessage( + role=Role.assistant, + content="Answer", + reasoning_content="hmm", + reasoning_signature="sig", + ) + ] + _, converted = mapper.prepare_messages(messages) + content = converted[0]["content"] + assert content[0] == {"type": "thinking", "thinking": "hmm", "signature": "sig"} + assert content[1]["type"] == "text" + + def test_assistant_with_reasoning_content(self, mapper): + messages = [ + LLMMessage( + role=Role.assistant, content="Answer", reasoning_content="thinking..." + ) + ] + _, converted = mapper.prepare_messages(messages) + content = converted[0]["content"] + assert content[0] == {"type": "thinking", "thinking": "thinking..."} + + def test_assistant_with_tool_calls(self, mapper): + messages = [ + LLMMessage( + role=Role.assistant, + content="Let me search.", + tool_calls=[ + ToolCall( + id="tc_1", + index=0, + function=FunctionCall(name="search", arguments='{"q": "test"}'), + ) + ], + ) + ] + _, converted = mapper.prepare_messages(messages) + content = converted[0]["content"] + tool_block = [b for b in content if b["type"] == "tool_use"][0] + assert tool_block["name"] == "search" + assert tool_block["input"] == {"q": "test"} + + def test_tool_result_appended_to_user(self, mapper): + messages = [ + LLMMessage(role=Role.user, content="Do it"), + LLMMessage(role=Role.tool, content="result", tool_call_id="tc_1"), + ] + _, converted = mapper.prepare_messages(messages) + # tool_result is merged into the preceding user message + assert len(converted) == 1 + assert converted[0]["role"] == "user" + blocks = converted[0]["content"] + assert any(b.get("type") == "tool_result" for b in blocks) + + def test_tool_result_new_user_when_no_prior(self, mapper): + messages = [LLMMessage(role=Role.tool, content="result", tool_call_id="tc_1")] + _, converted = mapper.prepare_messages(messages) + assert converted[0]["role"] == "user" + assert converted[0]["content"][0]["type"] == "tool_result" + + +class TestMapperPrepareTools: + def test_none_returns_none(self, mapper): + assert mapper.prepare_tools(None) is None + + def test_empty_returns_none(self, mapper): + assert mapper.prepare_tools([]) is None + + def test_converts_tools(self, mapper): + tools = [ + AvailableTool( + function=AvailableFunction( + name="search", + description="Search things", + parameters={"type": "object"}, + ) + ) + ] + result = mapper.prepare_tools(tools) + assert len(result) == 1 + assert result[0]["name"] == "search" + assert result[0]["input_schema"] == {"type": "object"} + + +class TestMapperToolChoice: + def test_none(self, mapper): + assert mapper.prepare_tool_choice(None) is None + + def test_auto(self, mapper): + assert mapper.prepare_tool_choice("auto") == {"type": "auto"} + + def test_none_str(self, mapper): + assert mapper.prepare_tool_choice("none") == {"type": "none"} + + def test_any(self, mapper): + assert mapper.prepare_tool_choice("any") == {"type": "any"} + + def test_required(self, mapper): + assert mapper.prepare_tool_choice("required") == {"type": "any"} + + def test_specific_tool(self, mapper): + tool = AvailableTool( + function=AvailableFunction(name="search", description="", parameters={}) + ) + assert mapper.prepare_tool_choice(tool) == {"type": "tool", "name": "search"} + + +class TestMapperParseResponse: + def test_text(self, mapper): + data = { + "content": [{"type": "text", "text": "Hello"}], + "usage": {"input_tokens": 10, "output_tokens": 5}, + } + chunk = mapper.parse_response(data) + assert chunk.message.content == "Hello" + assert chunk.usage.prompt_tokens == 10 + + def test_thinking(self, mapper): + data = { + "content": [ + {"type": "thinking", "thinking": "hmm", "signature": "sig"}, + {"type": "text", "text": "Answer"}, + ], + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + chunk = mapper.parse_response(data) + assert chunk.message.content == "Answer" + assert chunk.message.reasoning_content == "hmm" + assert chunk.message.reasoning_signature == "sig" + + def test_redacted_thinking(self, mapper): + data = { + "content": [ + {"type": "redacted_thinking", "data": "xyz"}, + {"type": "text", "text": "Answer"}, + ], + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + chunk = mapper.parse_response(data) + assert chunk.message.content == "Answer" + assert chunk.message.reasoning_content is None + + def test_tool_use(self, mapper): + data = { + "content": [ + {"type": "tool_use", "id": "t1", "name": "search", "input": {"q": "hi"}} + ], + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + chunk = mapper.parse_response(data) + assert chunk.message.tool_calls[0].function.name == "search" + assert json.loads(chunk.message.tool_calls[0].function.arguments) == {"q": "hi"} + + def test_cache_tokens(self, mapper): + data = { + "content": [{"type": "text", "text": "x"}], + "usage": { + "input_tokens": 10, + "cache_creation_input_tokens": 5, + "cache_read_input_tokens": 3, + "output_tokens": 7, + }, + } + chunk = mapper.parse_response(data) + assert chunk.usage.prompt_tokens == 18 + assert chunk.usage.completion_tokens == 7 + + +class TestMapperStreamingEvents: + def test_text_delta(self, mapper): + chunk, idx = mapper.parse_streaming_event( + "content_block_delta", + {"delta": {"type": "text_delta", "text": "hi"}, "index": 0}, + 0, + ) + assert chunk.message.content == "hi" + + def test_thinking_delta(self, mapper): + chunk, _ = mapper.parse_streaming_event( + "content_block_delta", + {"delta": {"type": "thinking_delta", "thinking": "hmm"}, "index": 0}, + 0, + ) + assert chunk.message.reasoning_content == "hmm" + + def test_tool_use_start(self, mapper): + chunk, idx = mapper.parse_streaming_event( + "content_block_start", + { + "content_block": {"type": "tool_use", "id": "t1", "name": "search"}, + "index": 2, + }, + 0, + ) + assert chunk.message.tool_calls[0].id == "t1" + assert idx == 2 + + def test_input_json_delta(self, mapper): + chunk, _ = mapper.parse_streaming_event( + "content_block_delta", + { + "delta": {"type": "input_json_delta", "partial_json": '{"q":'}, + "index": 1, + }, + 0, + ) + assert chunk.message.tool_calls[0].function.arguments == '{"q":' + + def test_message_start_usage(self, mapper): + chunk, _ = mapper.parse_streaming_event( + "message_start", + {"message": {"usage": {"input_tokens": 50, "cache_read_input_tokens": 10}}}, + 0, + ) + assert chunk.usage.prompt_tokens == 60 + + def test_message_delta_usage(self, mapper): + chunk, _ = mapper.parse_streaming_event( + "message_delta", {"usage": {"output_tokens": 42}}, 0 + ) + assert chunk.usage.completion_tokens == 42 + + def test_unknown_event(self, mapper): + chunk, idx = mapper.parse_streaming_event("ping", {}, 5) + assert chunk is None + assert idx == 5 + + def test_signature_delta(self, mapper): + chunk, _ = mapper.parse_streaming_event( + "content_block_delta", + {"delta": {"type": "signature_delta", "signature": "sig"}, "index": 0}, + 0, + ) + assert chunk is not None + assert chunk.message.reasoning_signature == "sig" + + +class TestAdapterPrepareRequest: + def test_basic(self, adapter, provider): + messages = [LLMMessage(role=Role.user, content="Hello")] + req = adapter.prepare_request( + model_name="claude-sonnet-4-20250514", + messages=messages, + temperature=0.5, + tools=None, + max_tokens=1024, + tool_choice=None, + enable_streaming=False, + provider=provider, + ) + + payload = json.loads(req.body) + assert payload["model"] == "claude-sonnet-4-20250514" + assert payload["max_tokens"] == 1024 + assert payload["temperature"] == 0.5 + assert req.endpoint == "/v1/messages" + assert req.headers["anthropic-version"] == "2023-06-01" + + def test_beta_features(self, adapter, provider): + messages = [LLMMessage(role=Role.user, content="Hello")] + req = adapter.prepare_request( + model_name="claude-sonnet-4-20250514", + messages=messages, + temperature=0.5, + tools=None, + max_tokens=1024, + tool_choice=None, + enable_streaming=False, + provider=provider, + ) + assert "prompt-caching-2024-07-31" in req.headers["anthropic-beta"] + assert "interleaved-thinking-2025-05-14" in req.headers["anthropic-beta"] + assert "fine-grained-tool-streaming-2025-05-14" in req.headers["anthropic-beta"] + + def test_api_key_header(self, adapter, provider): + messages = [LLMMessage(role=Role.user, content="Hello")] + req = adapter.prepare_request( + model_name="claude-sonnet-4-20250514", + messages=messages, + temperature=0.5, + tools=None, + max_tokens=1024, + tool_choice=None, + enable_streaming=False, + provider=provider, + api_key="sk-test-key", + ) + assert req.headers["x-api-key"] == "sk-test-key" + + def test_streaming(self, adapter, provider): + messages = [LLMMessage(role=Role.user, content="Hello")] + req = adapter.prepare_request( + model_name="claude-sonnet-4-20250514", + messages=messages, + temperature=0.5, + tools=None, + max_tokens=1024, + tool_choice=None, + enable_streaming=True, + provider=provider, + ) + assert json.loads(req.body)["stream"] is True + + def test_default_max_tokens(self, adapter, provider): + messages = [LLMMessage(role=Role.user, content="Hello")] + req = adapter.prepare_request( + model_name="claude-sonnet-4-20250514", + messages=messages, + temperature=0.5, + tools=None, + max_tokens=None, + tool_choice=None, + enable_streaming=False, + provider=provider, + ) + assert json.loads(req.body)["max_tokens"] == AnthropicAdapter.DEFAULT_MAX_TOKENS + + def test_with_thinking(self, adapter, provider): + messages = [LLMMessage(role=Role.user, content="Hello")] + req = adapter.prepare_request( + model_name="claude-sonnet-4-20250514", + messages=messages, + temperature=0.5, + tools=None, + max_tokens=1024, + tool_choice=None, + enable_streaming=False, + provider=provider, + thinking="medium", + ) + payload = json.loads(req.body) + assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10000} + assert payload["max_tokens"] == 1024 + assert payload["temperature"] == 1 + + def test_system_cached(self, adapter, provider): + messages = [ + LLMMessage(role=Role.system, content="Be helpful."), + LLMMessage(role=Role.user, content="Hello"), + ] + req = adapter.prepare_request( + model_name="claude-sonnet-4-20250514", + messages=messages, + temperature=0.5, + tools=None, + max_tokens=1024, + tool_choice=None, + enable_streaming=False, + provider=provider, + ) + payload = json.loads(req.body) + assert payload["system"][0]["cache_control"] == {"type": "ephemeral"} + + def test_with_tools(self, adapter, provider): + messages = [LLMMessage(role=Role.user, content="Hello")] + tools = [ + AvailableTool( + function=AvailableFunction( + name="test_tool", + description="A test tool", + parameters={"type": "object", "properties": {}}, + ) + ) + ] + req = adapter.prepare_request( + model_name="claude-sonnet-4-20250514", + messages=messages, + temperature=0.5, + tools=tools, + max_tokens=1024, + tool_choice=None, + enable_streaming=False, + provider=provider, + ) + payload = json.loads(req.body) + assert len(payload["tools"]) == 1 + assert payload["tools"][0]["name"] == "test_tool" + + @pytest.mark.parametrize( + "level,expected_budget", [("low", 1024), ("medium", 10_000), ("high", 32_000)] + ) + def test_thinking_levels_budget_model( + self, adapter, provider, level, expected_budget + ): + messages = [LLMMessage(role=Role.user, content="Hello")] + req = adapter.prepare_request( + model_name="claude-sonnet-4-20250514", + messages=messages, + temperature=0.5, + tools=None, + max_tokens=None, + tool_choice=None, + enable_streaming=False, + provider=provider, + thinking=level, + ) + payload = json.loads(req.body) + assert payload["thinking"] == { + "type": "enabled", + "budget_tokens": expected_budget, + } + assert payload["temperature"] == 1 + assert payload["max_tokens"] == expected_budget + 8192 + + @pytest.mark.parametrize("level", ["low", "medium", "high"]) + def test_thinking_levels_adaptive_model(self, adapter, provider, level): + messages = [LLMMessage(role=Role.user, content="Hello")] + req = adapter.prepare_request( + model_name="claude-opus-4-6-20260101", + messages=messages, + temperature=0.5, + tools=None, + max_tokens=None, + tool_choice=None, + enable_streaming=False, + provider=provider, + thinking=level, + ) + payload = json.loads(req.body) + assert payload["thinking"] == {"type": "adaptive"} + assert payload["output_config"] == {"effort": level} + assert payload["temperature"] == 1 + assert payload["max_tokens"] == 32_768 + + def test_history_forced_thinking_budget_model(self, adapter, provider): + messages = [ + LLMMessage(role=Role.user, content="Hello"), + LLMMessage( + role=Role.assistant, + content="Answer", + reasoning_content="thinking...", + reasoning_signature="sig", + ), + LLMMessage(role=Role.user, content="Follow up"), + ] + req = adapter.prepare_request( + model_name="claude-sonnet-4-20250514", + messages=messages, + temperature=0.5, + tools=None, + max_tokens=None, + tool_choice=None, + enable_streaming=False, + provider=provider, + ) + payload = json.loads(req.body) + assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10_000} + assert payload["temperature"] == 1 + assert payload["max_tokens"] == 18_192 + + def test_history_forced_thinking_adaptive_model(self, adapter, provider): + messages = [ + LLMMessage(role=Role.user, content="Hello"), + LLMMessage( + role=Role.assistant, + content="Answer", + reasoning_content="thinking...", + reasoning_signature="sig", + ), + LLMMessage(role=Role.user, content="Follow up"), + ] + req = adapter.prepare_request( + model_name="claude-opus-4-6-20260101", + messages=messages, + temperature=0.5, + tools=None, + max_tokens=None, + tool_choice=None, + enable_streaming=False, + provider=provider, + ) + payload = json.loads(req.body) + assert payload["thinking"] == {"type": "adaptive"} + assert payload["output_config"] == {"effort": "medium"} + assert payload["max_tokens"] == 32_768 + + +class TestAdapterParseResponse: + def test_non_streaming(self, adapter, provider): + data = { + "content": [{"type": "text", "text": "Hello!"}], + "usage": {"input_tokens": 10, "output_tokens": 5}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "Hello!" + assert chunk.usage.prompt_tokens == 10 + + def test_streaming_text_delta(self, adapter, provider): + data = { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "Hi"}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "Hi" + + def test_streaming_message_start(self, adapter, provider): + data = {"type": "message_start", "message": {"usage": {"input_tokens": 100}}} + chunk = adapter.parse_response(data, provider) + assert chunk.usage.prompt_tokens == 100 + + def test_streaming_unknown_returns_empty(self, adapter, provider): + data = {"type": "ping"} + chunk = adapter.parse_response(data, provider) + assert chunk.message.role == Role.assistant + assert chunk.message.content is None + + def test_cache_control_last_user_message(self, adapter): + messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}] + adapter._add_cache_control_to_last_user_message(messages) + assert messages[0]["content"][0]["cache_control"] == {"type": "ephemeral"} + + def test_cache_control_skips_non_user(self, adapter): + messages = [ + {"role": "assistant", "content": [{"type": "text", "text": "Hello"}]} + ] + adapter._add_cache_control_to_last_user_message(messages) + assert "cache_control" not in messages[0]["content"][0] + + def test_cache_control_empty(self, adapter): + messages: list[dict] = [] + adapter._add_cache_control_to_last_user_message(messages) + assert messages == [] diff --git a/tests/backend/test_vertex_anthropic_adapter.py b/tests/backend/test_vertex_anthropic_adapter.py new file mode 100644 index 0000000..846dfdd --- /dev/null +++ b/tests/backend/test_vertex_anthropic_adapter.py @@ -0,0 +1,591 @@ +from __future__ import annotations + +import json +from unittest.mock import patch + +import pytest + +from vibe.core.config import ProviderConfig +from vibe.core.llm.backend.vertex import ( + VertexAnthropicAdapter, + build_vertex_base_url, + build_vertex_endpoint, +) +from vibe.core.types import AvailableFunction, AvailableTool, LLMMessage, Role + + +@pytest.fixture +def adapter(): + return VertexAnthropicAdapter() + + +@pytest.fixture +def provider(): + return ProviderConfig( + name="vertex", + api_base="", + project_id="test-project", + region="us-central1", + api_style="vertex-anthropic", + ) + + +class TestBuildVertexEndpoint: + def test_non_streaming(self): + endpoint = build_vertex_endpoint( + "us-central1", "my-project", "claude-3-5-sonnet" + ) + assert endpoint == ( + "/v1/projects/my-project/locations/us-central1/" + "publishers/anthropic/models/claude-3-5-sonnet:rawPredict" + ) + + def test_streaming(self): + endpoint = build_vertex_endpoint( + "us-central1", "my-project", "claude-3-5-sonnet", streaming=True + ) + assert endpoint == ( + "/v1/projects/my-project/locations/us-central1/" + "publishers/anthropic/models/claude-3-5-sonnet:streamRawPredict" + ) + + def test_base_url(self): + base = build_vertex_base_url("us-central1") + assert base == "https://us-central1-aiplatform.googleapis.com" + + def test_global_endpoint(self): + endpoint = build_vertex_endpoint("global", "my-project", "claude-3-5-sonnet") + assert endpoint == ( + "/v1/projects/my-project/locations/global/" + "publishers/anthropic/models/claude-3-5-sonnet:rawPredict" + ) + + def test_global_base_url(self): + base = build_vertex_base_url("global") + assert base == "https://aiplatform.googleapis.com" + + +class TestPrepareRequest: + @patch( + "vibe.core.llm.backend.vertex.get_vertex_access_token", + return_value="fake-token", + ) + def test_basic_request(self, mock_token, adapter, provider): + messages = [LLMMessage(role=Role.user, content="Hello")] + req = adapter.prepare_request( + model_name="claude-3-5-sonnet", + messages=messages, + temperature=0.5, + tools=None, + max_tokens=1024, + tool_choice=None, + enable_streaming=False, + provider=provider, + ) + + payload = json.loads(req.body) + assert payload["anthropic_version"] == "vertex-2023-10-16" + assert "model" not in payload + assert payload["max_tokens"] == 1024 + assert payload["temperature"] == 0.5 + assert req.headers["Authorization"] == "Bearer fake-token" + assert req.headers["anthropic-beta"] == adapter.BETA_FEATURES + assert "rawPredict" in req.endpoint + assert "streamRawPredict" not in req.endpoint + assert req.base_url == "https://us-central1-aiplatform.googleapis.com" + + @patch( + "vibe.core.llm.backend.vertex.get_vertex_access_token", + return_value="fake-token", + ) + def test_streaming_request(self, mock_token, adapter, provider): + messages = [LLMMessage(role=Role.user, content="Hello")] + req = adapter.prepare_request( + model_name="claude-3-5-sonnet", + messages=messages, + temperature=0.5, + tools=None, + max_tokens=1024, + tool_choice=None, + enable_streaming=True, + provider=provider, + ) + + payload = json.loads(req.body) + assert payload.get("stream") is True + assert "streamRawPredict" in req.endpoint + + @patch( + "vibe.core.llm.backend.vertex.get_vertex_access_token", + return_value="fake-token", + ) + def test_no_beta_features_for_vertex(self, mock_token, adapter, provider): + """Vertex AI doesn't support the same beta features as direct Anthropic API.""" + messages = [LLMMessage(role=Role.user, content="Hello")] + req = adapter.prepare_request( + model_name="claude-3-5-sonnet", + messages=messages, + temperature=0.5, + tools=None, + max_tokens=1024, + tool_choice=None, + enable_streaming=False, + provider=provider, + ) + + # Vertex AI doesn't support prompt-caching or other beta features + assert req.headers.get("anthropic-beta", "") == "" + + @patch( + "vibe.core.llm.backend.vertex.get_vertex_access_token", + return_value="fake-token", + ) + def test_with_extended_thinking(self, mock_token, adapter, provider): + messages = [LLMMessage(role=Role.user, content="Hello")] + req = adapter.prepare_request( + model_name="claude-3-5-sonnet", + messages=messages, + temperature=0.5, + tools=None, + max_tokens=1024, + tool_choice=None, + enable_streaming=False, + provider=provider, + thinking="medium", + ) + + payload = json.loads(req.body) + assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10000} + assert payload["max_tokens"] == 1024 + assert payload["temperature"] == 1 + + @patch( + "vibe.core.llm.backend.vertex.get_vertex_access_token", + return_value="fake-token", + ) + def test_with_tools(self, mock_token, adapter, provider): + messages = [LLMMessage(role=Role.user, content="Hello")] + tools = [ + AvailableTool( + function=AvailableFunction( + name="test_tool", + description="A test tool", + parameters={"type": "object", "properties": {}}, + ) + ) + ] + req = adapter.prepare_request( + model_name="claude-3-5-sonnet", + messages=messages, + temperature=0.5, + tools=tools, + max_tokens=1024, + tool_choice=None, + enable_streaming=False, + provider=provider, + ) + + payload = json.loads(req.body) + assert len(payload["tools"]) == 1 + assert payload["tools"][0]["name"] == "test_tool" + + def test_missing_project_id(self, adapter): + provider = ProviderConfig( + name="vertex", + api_base="", + region="us-central1", + api_style="vertex-anthropic", + ) + with pytest.raises(ValueError, match="project_id"): + adapter.prepare_request( + model_name="claude-3-5-sonnet", + messages=[LLMMessage(role=Role.user, content="Hello")], + temperature=0.5, + tools=None, + max_tokens=1024, + tool_choice=None, + enable_streaming=False, + provider=provider, + ) + + def test_missing_region(self, adapter): + provider = ProviderConfig( + name="vertex", + api_base="", + project_id="test-project", + api_style="vertex-anthropic", + ) + with pytest.raises(ValueError, match="region"): + adapter.prepare_request( + model_name="claude-3-5-sonnet", + messages=[LLMMessage(role=Role.user, content="Hello")], + temperature=0.5, + tools=None, + max_tokens=1024, + tool_choice=None, + enable_streaming=False, + provider=provider, + ) + + @patch( + "vibe.core.llm.backend.vertex.get_vertex_access_token", + return_value="fake-token", + ) + def test_default_max_tokens(self, mock_token, adapter, provider): + messages = [LLMMessage(role=Role.user, content="Hello")] + req = adapter.prepare_request( + model_name="claude-3-5-sonnet", + messages=messages, + temperature=0.5, + tools=None, + max_tokens=None, + tool_choice=None, + enable_streaming=False, + provider=provider, + ) + + payload = json.loads(req.body) + assert payload["max_tokens"] == adapter.DEFAULT_MAX_TOKENS + + +class TestParseFullResponse: + def test_simple_text_response(self, adapter, provider): + data = { + "content": [{"type": "text", "text": "Hello there!"}], + "usage": {"input_tokens": 10, "output_tokens": 5}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "Hello there!" + assert chunk.usage.prompt_tokens == 10 + assert chunk.usage.completion_tokens == 5 + + def test_response_with_tool_calls(self, adapter, provider): + data = { + "content": [ + {"type": "text", "text": "Let me help."}, + { + "type": "tool_use", + "id": "tool_123", + "name": "search", + "input": {"query": "test"}, + }, + ], + "usage": {"input_tokens": 20, "output_tokens": 15}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "Let me help." + assert len(chunk.message.tool_calls) == 1 + assert chunk.message.tool_calls[0].id == "tool_123" + assert chunk.message.tool_calls[0].function.name == "search" + assert json.loads(chunk.message.tool_calls[0].function.arguments) == { + "query": "test" + } + + def test_response_with_thinking(self, adapter, provider): + data = { + "content": [ + { + "type": "thinking", + "thinking": "Let me think...", + "signature": "sig123", + }, + {"type": "text", "text": "Here's my answer."}, + ], + "usage": {"input_tokens": 30, "output_tokens": 20}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "Here's my answer." + assert chunk.message.reasoning_content == "Let me think..." + assert chunk.message.reasoning_signature == "sig123" + + def test_response_with_cache_tokens(self, adapter, provider): + data = { + "content": [{"type": "text", "text": "Hello"}], + "usage": { + "input_tokens": 10, + "cache_creation_input_tokens": 5, + "cache_read_input_tokens": 3, + "output_tokens": 7, + }, + } + chunk = adapter.parse_response(data, provider) + assert chunk.usage.prompt_tokens == 18 + assert chunk.usage.completion_tokens == 7 + + def test_response_with_redacted_thinking(self, adapter, provider): + data = { + "content": [ + {"type": "redacted_thinking", "data": "redacted_data_here"}, + {"type": "text", "text": "Answer."}, + ], + "usage": {"input_tokens": 10, "output_tokens": 5}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "Answer." + assert chunk.message.reasoning_content is None + + def test_response_empty_usage(self, adapter, provider): + data = {"content": [{"type": "text", "text": "Hello"}], "usage": {}} + chunk = adapter.parse_response(data, provider) + assert chunk.usage.prompt_tokens == 0 + assert chunk.usage.completion_tokens == 0 + + +class TestStreamingEvents: + def test_message_start(self, adapter, provider): + data = { + "type": "message_start", + "message": { + "usage": { + "input_tokens": 100, + "cache_creation_input_tokens": 20, + "cache_read_input_tokens": 10, + } + }, + } + chunk = adapter.parse_response(data, provider) + assert chunk.usage is not None + assert chunk.usage.prompt_tokens == 130 + assert chunk.usage.completion_tokens == 0 + + def test_message_start_without_usage(self, adapter, provider): + data = {"type": "message_start", "message": {}} + chunk = adapter.parse_response(data, provider) + assert chunk.message.role == Role.assistant + + def test_content_block_start_tool_use(self, adapter, provider): + data = { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "tool_use", "id": "tool_abc", "name": "search"}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.tool_calls is not None + assert len(chunk.message.tool_calls) == 1 + assert chunk.message.tool_calls[0].id == "tool_abc" + assert chunk.message.tool_calls[0].function.name == "search" + assert chunk.message.tool_calls[0].index == 0 + + def test_content_block_start_thinking(self, adapter, provider): + data = { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": ""}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.reasoning_content is not None + + def test_content_block_start_redacted_thinking(self, adapter, provider): + data = { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "redacted_thinking", "data": "abc"}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content is None + assert chunk.message.reasoning_content is None + + def test_content_block_delta_text(self, adapter, provider): + data = { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "Hello"}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "Hello" + + def test_content_block_delta_thinking(self, adapter, provider): + data = { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": "I think..."}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.reasoning_content == "I think..." + + def test_content_block_delta_input_json(self, adapter, provider): + data = { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "input_json_delta", "partial_json": '{"key":'}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.tool_calls is not None + assert chunk.message.tool_calls[0].function.arguments == '{"key":' + + def test_content_block_stop(self, adapter, provider): + data = {"type": "content_block_stop", "index": 0} + chunk = adapter.parse_response(data, provider) + assert chunk.message.content is None + assert chunk.message.reasoning_content is None + + def test_message_delta_with_usage(self, adapter, provider): + data = {"type": "message_delta", "usage": {"output_tokens": 42}} + chunk = adapter.parse_response(data, provider) + assert chunk.usage is not None + assert chunk.usage.completion_tokens == 42 + assert chunk.usage.prompt_tokens == 0 + + def test_message_delta_without_usage(self, adapter, provider): + data = {"type": "message_delta", "usage": {}} + chunk = adapter.parse_response(data, provider) + assert chunk.message.role == Role.assistant + + def test_unknown_event_returns_empty_chunk(self, adapter, provider): + data = {"type": "ping"} + chunk = adapter.parse_response(data, provider) + assert chunk.message.role == Role.assistant + assert chunk.message.content is None + + def test_signature_delta(self, adapter, provider): + data = { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "signature_delta", "signature": "sig_abc"}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.reasoning_signature == "sig_abc" + + def test_message_start_resets_state(self, adapter, provider): + adapter._current_index = 5 + + data = {"type": "message_start", "message": {"usage": {"input_tokens": 10}}} + adapter.parse_response(data, provider) + assert adapter._current_index == 0 + + def test_full_streaming_sequence(self, adapter, provider): + chunks = [] + + # message_start + chunks.append( + adapter.parse_response( + {"type": "message_start", "message": {"usage": {"input_tokens": 50}}}, + provider, + ) + ) + assert chunks[-1].usage.prompt_tokens == 50 + + # thinking block + adapter.parse_response( + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": ""}, + }, + provider, + ) + chunks.append( + adapter.parse_response( + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": "Analyzing..."}, + }, + provider, + ) + ) + assert chunks[-1].message.reasoning_content == "Analyzing..." + adapter.parse_response({"type": "content_block_stop", "index": 0}, provider) + + # text block + chunks.append( + adapter.parse_response( + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "text_delta", "text": "Here's the result."}, + }, + provider, + ) + ) + assert chunks[-1].message.content == "Here's the result." + + # tool use + chunks.append( + adapter.parse_response( + { + "type": "content_block_start", + "index": 2, + "content_block": { + "type": "tool_use", + "id": "tool_1", + "name": "search", + }, + }, + provider, + ) + ) + assert chunks[-1].message.tool_calls[0].function.name == "search" + + # message_delta with final usage + chunks.append( + adapter.parse_response( + {"type": "message_delta", "usage": {"output_tokens": 100}}, provider + ) + ) + assert chunks[-1].usage.completion_tokens == 100 + + +class TestHelperMethods: + def test_has_thinking_content_true(self, adapter): + messages = [ + {"role": "user", "content": "Hello"}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Let me think..."}, + {"type": "text", "text": "Answer"}, + ], + }, + ] + assert adapter._has_thinking_content(messages) is True + + def test_has_thinking_content_false(self, adapter): + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Just text"}, + ] + assert adapter._has_thinking_content(messages) is False + + def test_has_thinking_content_empty(self, adapter): + assert adapter._has_thinking_content([]) is False + + def test_has_thinking_content_non_list_content(self, adapter): + messages = [ + {"role": "assistant", "content": [{"type": "text", "text": "no thinking"}]} + ] + assert adapter._has_thinking_content(messages) is False + + def test_add_cache_control_to_last_user_message(self, adapter): + messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}] + adapter._add_cache_control_to_last_user_message(messages) + assert messages[0]["content"][0]["cache_control"] == {"type": "ephemeral"} + + def test_add_cache_control_skips_non_user(self, adapter): + messages = [ + {"role": "assistant", "content": [{"type": "text", "text": "Hello"}]} + ] + adapter._add_cache_control_to_last_user_message(messages) + assert "cache_control" not in messages[0]["content"][0] + + def test_add_cache_control_skips_string_content(self, adapter): + messages = [{"role": "user", "content": "Hello"}] + adapter._add_cache_control_to_last_user_message(messages) + assert messages[0]["content"] == "Hello" + + def test_add_cache_control_tool_result(self, adapter): + messages = [ + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "123", "content": "result"} + ], + } + ] + adapter._add_cache_control_to_last_user_message(messages) + assert messages[0]["content"][0]["cache_control"] == {"type": "ephemeral"} + + def test_add_cache_control_empty_messages(self, adapter): + messages: list[dict] = [] + adapter._add_cache_control_to_last_user_message(messages) + assert messages == [] diff --git a/tests/cli/test_clipboard.py b/tests/cli/test_clipboard.py index e2b57e0..066b59d 100644 --- a/tests/cli/test_clipboard.py +++ b/tests/cli/test_clipboard.py @@ -74,7 +74,8 @@ def test_copy_selection_to_clipboard_no_notification( del widgets[0].text_selection mock_app.query.return_value = widgets - copy_selection_to_clipboard(mock_app) + result = copy_selection_to_clipboard(mock_app) + assert result is None mock_app.notify.assert_not_called() @@ -87,8 +88,9 @@ def test_copy_selection_to_clipboard_success( ) mock_app.query.return_value = [widget] - copy_selection_to_clipboard(mock_app) + result = copy_selection_to_clipboard(mock_app) + assert result == "selected text" mock_copy_osc52.assert_called_once_with("selected text") mock_app.notify.assert_called_once_with( '"selected text" copied to clipboard', @@ -109,8 +111,9 @@ def test_copy_selection_to_clipboard_failure( mock_copy_osc52.side_effect = Exception("OSC52 failed") - copy_selection_to_clipboard(mock_app) + result = copy_selection_to_clipboard(mock_app) + assert result is None mock_copy_osc52.assert_called_once_with("selected text") mock_app.notify.assert_called_once_with( "Failed to copy - clipboard not available", severity="warning", timeout=3 @@ -129,8 +132,9 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No mock_app.query.return_value = [widget1, widget2, widget3] with patch("vibe.cli.clipboard._copy_osc52") as mock_copy_osc52: - copy_selection_to_clipboard(mock_app) + result = copy_selection_to_clipboard(mock_app) + assert result == "first selection\nsecond selection" mock_copy_osc52.assert_called_once_with("first selection\nsecond selection") mock_app.notify.assert_called_once_with( '"first selection\u23cesecond selection" copied to clipboard', @@ -148,8 +152,9 @@ def test_copy_selection_to_clipboard_preview_shortening(mock_app: MagicMock) -> mock_app.query.return_value = [widget] with patch("vibe.cli.clipboard._copy_osc52") as mock_copy_osc52: - copy_selection_to_clipboard(mock_app) + result = copy_selection_to_clipboard(mock_app) + assert result == long_text mock_copy_osc52.assert_called_once_with(long_text) notification_call = mock_app.notify.call_args assert notification_call is not None diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py new file mode 100644 index 0000000..4c5fefb --- /dev/null +++ b/tests/cli/test_commands.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from vibe.cli.commands import Command, CommandRegistry + + +class TestCommandRegistry: + def test_get_command_name_returns_canonical_name_for_alias(self) -> None: + registry = CommandRegistry() + assert registry.get_command_name("/help") == "help" + assert registry.get_command_name("/config") == "config" + assert registry.get_command_name("/model") == "config" + assert registry.get_command_name("/clear") == "clear" + assert registry.get_command_name("/exit") == "exit" + + def test_get_command_name_normalizes_input(self) -> None: + registry = CommandRegistry() + assert registry.get_command_name(" /help ") == "help" + assert registry.get_command_name("/HELP") == "help" + + def test_get_command_name_returns_none_for_unknown(self) -> None: + registry = CommandRegistry() + assert registry.get_command_name("/unknown") is None + assert registry.get_command_name("hello") is None + assert registry.get_command_name("") is None + + def test_find_command_returns_command_when_alias_matches(self) -> None: + registry = CommandRegistry() + cmd = registry.find_command("/help") + assert cmd is not None + assert cmd.handler == "_show_help" + assert isinstance(cmd, Command) + + def test_find_command_returns_none_when_no_match(self) -> None: + registry = CommandRegistry() + assert registry.find_command("/nonexistent") is None + + def test_find_command_uses_get_command_name(self) -> None: + """find_command and get_command_name stay in sync for same input.""" + registry = CommandRegistry() + for alias in ["/help", "/config", "/clear", "/exit"]: + cmd_name = registry.get_command_name(alias) + cmd = registry.find_command(alias) + if cmd_name is None: + assert cmd is None + else: + assert cmd is not None + assert cmd_name in registry.commands + assert registry.commands[cmd_name] is cmd + + def test_excluded_commands_not_in_registry(self) -> None: + registry = CommandRegistry(excluded_commands=["exit"]) + assert registry.get_command_name("/exit") is None + assert registry.find_command("/exit") is None + assert registry.get_command_name("/help") == "help" diff --git a/tests/conftest.py b/tests/conftest.py index 53be439..db9a896 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -94,6 +94,22 @@ def _mock_update_commands(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["true"]) +@pytest.fixture(autouse=True) +def telemetry_events(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + + def record_telemetry( + self: Any, event_name: str, properties: dict[str, Any] + ) -> None: + events.append({"event_name": event_name, "properties": properties}) + + monkeypatch.setattr( + "vibe.core.telemetry.send.TelemetryClient.send_telemetry_event", + record_telemetry, + ) + return events + + @pytest.fixture def vibe_app() -> VibeApp: return build_test_vibe_app() diff --git a/tests/core/test_config_paths.py b/tests/core/test_config_paths.py new file mode 100644 index 0000000..3fc1111 --- /dev/null +++ b/tests/core/test_config_paths.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from vibe.core.paths.config_paths import resolve_local_skills_dirs + + +class TestResolveLocalSkillsDirs: + def test_returns_empty_list_when_dir_not_trusted(self, tmp_path: Path) -> None: + (tmp_path / ".vibe" / "skills").mkdir(parents=True) + with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: + mock_tfm.is_trusted.return_value = False + assert resolve_local_skills_dirs(tmp_path) == [] + + def test_returns_empty_list_when_trusted_but_no_skills_dirs( + self, tmp_path: Path + ) -> None: + with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: + mock_tfm.is_trusted.return_value = True + assert resolve_local_skills_dirs(tmp_path) == [] + + def test_returns_vibe_skills_only_when_only_it_exists(self, tmp_path: Path) -> None: + vibe_skills = tmp_path / ".vibe" / "skills" + vibe_skills.mkdir(parents=True) + with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: + mock_tfm.is_trusted.return_value = True + result = resolve_local_skills_dirs(tmp_path) + assert result == [vibe_skills] + + def test_returns_agents_skills_only_when_only_it_exists( + self, tmp_path: Path + ) -> None: + agents_skills = tmp_path / ".agents" / "skills" + agents_skills.mkdir(parents=True) + with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: + mock_tfm.is_trusted.return_value = True + result = resolve_local_skills_dirs(tmp_path) + assert result == [agents_skills] + + def test_returns_both_in_order_when_both_exist(self, tmp_path: Path) -> None: + vibe_skills = tmp_path / ".vibe" / "skills" + agents_skills = tmp_path / ".agents" / "skills" + vibe_skills.mkdir(parents=True) + agents_skills.mkdir(parents=True) + with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: + mock_tfm.is_trusted.return_value = True + result = resolve_local_skills_dirs(tmp_path) + assert result == [vibe_skills, agents_skills] + + def test_ignores_vibe_skills_when_file_not_dir(self, tmp_path: Path) -> None: + (tmp_path / ".vibe").mkdir() + (tmp_path / ".vibe" / "skills").write_text("", encoding="utf-8") + with patch("vibe.core.paths.config_paths.trusted_folders_manager") as mock_tfm: + mock_tfm.is_trusted.return_value = True + result = resolve_local_skills_dirs(tmp_path) + assert result == [] diff --git a/tests/core/test_file_logging.py b/tests/core/test_file_logging.py new file mode 100644 index 0000000..966f22c --- /dev/null +++ b/tests/core/test_file_logging.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path +from textwrap import dedent +from unittest.mock import MagicMock, patch + +import pytest + +from vibe.core.utils import StructuredLogFormatter, apply_logging_config + + +@pytest.fixture +def mock_log_dir(tmp_path: Path): + """Mock LOG_DIR and LOG_FILE to use tmp_path for testing.""" + mock_dir = MagicMock() + mock_dir.path = tmp_path + mock_file = MagicMock() + mock_file.path = tmp_path / "vibe.log" + with ( + patch("vibe.core.utils.LOG_DIR", mock_dir), + patch("vibe.core.utils.LOG_FILE", mock_file), + ): + yield tmp_path + + +class TestStructuredFormatter: + def test_format_contains_required_fields(self) -> None: + formatter = StructuredLogFormatter() + record = logging.LogRecord( + name="test_logger", + level=logging.INFO, + pathname="test.py", + lineno=1, + msg="Test message", + args=(), + exc_info=None, + ) + + output = formatter.format(record) + + parts = output.split(" ", 4) + assert len(parts) == 5 + assert "T" in parts[0] + assert parts[1].isdigit() + assert parts[2].isdigit() + assert parts[3] == "INFO" + assert parts[4] == "Test message" + + def test_format_includes_exception(self) -> None: + formatter = StructuredLogFormatter() + try: + raise ValueError("test error") + except ValueError: + import sys + + exc_info = sys.exc_info() + + record = logging.LogRecord( + name="test_logger", + level=logging.ERROR, + pathname="test.py", + lineno=1, + msg="Error occurred", + args=(), + exc_info=exc_info, + ) + + output = formatter.format(record) + + assert "Error occurred" in output + assert "ValueError" in output + assert "test error" in output + + def test_format_escapes_newlines_in_message(self) -> None: + formatter = StructuredLogFormatter() + multiline_msg = dedent(""" + Line one + Line two + Line three""") + record = logging.LogRecord( + name="test_logger", + level=logging.INFO, + pathname="test.py", + lineno=1, + msg=multiline_msg, + args=(), + exc_info=None, + ) + + output = formatter.format(record) + + assert "\n" not in output + assert "Line one\\nLine two\\nLine three" in output + + def test_format_escapes_newlines_in_exception(self) -> None: + formatter = StructuredLogFormatter() + try: + raise ValueError("test error") + except ValueError: + import sys + + exc_info = sys.exc_info() + + record = logging.LogRecord( + name="test_logger", + level=logging.ERROR, + pathname="test.py", + lineno=1, + msg="Error occurred", + args=(), + exc_info=exc_info, + ) + + output = formatter.format(record) + + assert "\n" not in output + assert "ValueError" in output + assert "test error" in output + assert "\\n" in output + + def test_format_output_is_single_line(self) -> None: + formatter = StructuredLogFormatter() + try: + error_msg = dedent(""" + multi + line + error""") + raise RuntimeError(error_msg) + except RuntimeError: + import sys + + exc_info = sys.exc_info() + + multiline_msg = dedent(""" + Something + went + wrong""") + record = logging.LogRecord( + name="test_logger", + level=logging.ERROR, + pathname="test.py", + lineno=1, + msg=multiline_msg, + args=(), + exc_info=exc_info, + ) + + output = formatter.format(record) + lines = output.split("\n") + + assert len(lines) == 1 + + +class TestApplyLoggingConfig: + def test_adds_handler_to_logger( + self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("LOG_LEVEL", "DEBUG") + test_logger = logging.getLogger("test_apply_logging") + initial_handler_count = len(test_logger.handlers) + + apply_logging_config(test_logger) + + assert len(test_logger.handlers) == initial_handler_count + 1 + + def test_creates_log_file( + self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("LOG_LEVEL", "DEBUG") + test_logger = logging.getLogger("test_log_file") + test_logger.setLevel(logging.DEBUG) + + apply_logging_config(test_logger) + test_logger.info("Test log entry") + + log_file = mock_log_dir / "vibe.log" + assert log_file.exists() + + def test_log_entry_format( + self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("LOG_LEVEL", "DEBUG") + test_logger = logging.getLogger("test_format") + test_logger.setLevel(logging.DEBUG) + + apply_logging_config(test_logger) + test_logger.warning("Test warning message") + + log_file = mock_log_dir / "vibe.log" + content = log_file.read_text() + + assert "WARNING" in content + assert "Test warning message" in content + + def test_respects_log_level( + self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("LOG_LEVEL", "WARNING") + test_logger = logging.getLogger("test_level_filter") + test_logger.setLevel(logging.DEBUG) + + apply_logging_config(test_logger) + test_logger.debug("Debug message") + test_logger.info("Info message") + test_logger.warning("Warning message") + + log_file = mock_log_dir / "vibe.log" + content = log_file.read_text() + + assert "Debug message" not in content + assert "Info message" not in content + assert "Warning message" in content + + def test_creates_log_directory_if_missing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + log_dir = tmp_path / "nested" / "logs" + mock_dir = MagicMock() + mock_dir.path = log_dir + mock_file = MagicMock() + mock_file.path = log_dir / "vibe.log" + with ( + patch("vibe.core.utils.LOG_DIR", mock_dir), + patch("vibe.core.utils.LOG_FILE", mock_file), + ): + monkeypatch.setenv("LOG_LEVEL", "DEBUG") + test_logger = logging.getLogger("test_mkdir") + + apply_logging_config(test_logger) + + assert log_dir.exists() + + def test_debug_mode_overrides_log_level( + self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("LOG_LEVEL", "WARNING") + monkeypatch.setenv("DEBUG_MODE", "true") + test_logger = logging.getLogger("test_debug_mode") + test_logger.setLevel(logging.DEBUG) + + apply_logging_config(test_logger) + test_logger.debug("Debug message") + + log_file = mock_log_dir / "vibe.log" + content = log_file.read_text() + + assert "Debug message" in content + + def test_invalid_log_level_defaults_to_warning( + self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("LOG_LEVEL", "INVALID") + test_logger = logging.getLogger("test_invalid_level") + test_logger.setLevel(logging.DEBUG) + + apply_logging_config(test_logger) + test_logger.info("Info message") + test_logger.warning("Warning message") + + log_file = mock_log_dir / "vibe.log" + content = log_file.read_text() + + assert "Info message" not in content + assert "Warning message" in content + + def test_log_max_bytes_from_env( + self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("LOG_MAX_BYTES", "5242880") # 5 MB + monkeypatch.setenv("LOG_LEVEL", "DEBUG") + test_logger = logging.getLogger("test_max_bytes") + + apply_logging_config(test_logger) + + # Verify handler was added with correct maxBytes + handler = test_logger.handlers[-1] + assert isinstance(handler, RotatingFileHandler) + assert handler.maxBytes == 5242880 diff --git a/tests/core/test_proxy_setup.py b/tests/core/test_proxy_setup.py new file mode 100644 index 0000000..7bd3859 --- /dev/null +++ b/tests/core/test_proxy_setup.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +import pytest + +from vibe.core.paths.global_paths import GLOBAL_ENV_FILE +from vibe.core.proxy_setup import ( + SUPPORTED_PROXY_VARS, + ProxySetupError, + get_current_proxy_settings, + parse_proxy_command, + set_proxy_var, + unset_proxy_var, +) + + +def _write_env_file(content: str) -> None: + GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True) + GLOBAL_ENV_FILE.path.write_text(content, encoding="utf-8") + + +class TestProxySetupError: + def test_inherits_from_exception(self) -> None: + assert issubclass(ProxySetupError, Exception) + + def test_preserves_message(self) -> None: + error = ProxySetupError("test message") + assert str(error) == "test message" + + +class TestSupportedProxyVars: + def test_contains_all_expected_keys(self) -> None: + expected_keys = { + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + } + assert set(SUPPORTED_PROXY_VARS.keys()) == expected_keys + + def test_all_keys_are_uppercase(self) -> None: + for key in SUPPORTED_PROXY_VARS: + assert key == key.upper() + + def test_all_values_are_non_empty_strings(self) -> None: + for value in SUPPORTED_PROXY_VARS.values(): + assert isinstance(value, str) + assert len(value) > 0 + + +class TestGetCurrentProxySettings: + def test_returns_all_none_when_env_file_does_not_exist(self) -> None: + result = get_current_proxy_settings() + + assert all(value is None for value in result.values()) + + def test_returns_dict_with_all_supported_keys(self) -> None: + result = get_current_proxy_settings() + + assert set(result.keys()) == set(SUPPORTED_PROXY_VARS.keys()) + + def test_returns_values_from_env_file(self) -> None: + _write_env_file( + "HTTP_PROXY=http://proxy:8080\nHTTPS_PROXY=https://proxy:8443\n" + ) + + result = get_current_proxy_settings() + + assert result["HTTP_PROXY"] == "http://proxy:8080" + assert result["HTTPS_PROXY"] == "https://proxy:8443" + + def test_returns_none_for_unset_keys(self) -> None: + _write_env_file("HTTP_PROXY=http://proxy:8080\n") + + result = get_current_proxy_settings() + + assert result["HTTP_PROXY"] == "http://proxy:8080" + assert result["HTTPS_PROXY"] is None + assert result["ALL_PROXY"] is None + + def test_ignores_non_proxy_vars_in_env_file(self) -> None: + _write_env_file("HTTP_PROXY=http://proxy:8080\nOTHER_VAR=ignored\n") + + result = get_current_proxy_settings() + + assert "OTHER_VAR" not in result + assert result["HTTP_PROXY"] == "http://proxy:8080" + + def test_handles_values_with_special_characters(self) -> None: + _write_env_file("HTTP_PROXY=http://user:p@ss@proxy:8080\n") + + result = get_current_proxy_settings() + + assert result["HTTP_PROXY"] == "http://user:p@ss@proxy:8080" + + def test_returns_all_none_when_env_file_read_fails( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + _write_env_file("HTTP_PROXY=http://proxy:8080\n") + + def raise_error(*args, **kwargs): + raise OSError("Permission denied") + + monkeypatch.setattr("vibe.core.proxy_setup.dotenv_values", raise_error) + + result = get_current_proxy_settings() + + assert all(value is None for value in result.values()) + + +class TestSetProxyVar: + def test_sets_valid_proxy_var(self) -> None: + set_proxy_var("HTTP_PROXY", "http://proxy:8080") + + result = get_current_proxy_settings() + assert result["HTTP_PROXY"] == "http://proxy:8080" + + @pytest.mark.parametrize("key", SUPPORTED_PROXY_VARS.keys()) + def test_sets_all_supported_vars(self, key: str) -> None: + set_proxy_var(key, "test-value") + + result = get_current_proxy_settings() + assert result[key] == "test-value" + + def test_uppercases_key_before_validation(self) -> None: + set_proxy_var("http_proxy", "http://proxy:8080") + + result = get_current_proxy_settings() + assert result["HTTP_PROXY"] == "http://proxy:8080" + + def test_raises_error_for_unknown_key(self) -> None: + with pytest.raises(ProxySetupError) as exc_info: + set_proxy_var("UNKNOWN_KEY", "value") + + assert "Unknown key 'UNKNOWN_KEY'" in str(exc_info.value) + + def test_error_message_contains_supported_keys(self) -> None: + with pytest.raises(ProxySetupError) as exc_info: + set_proxy_var("UNKNOWN_KEY", "value") + + error_msg = str(exc_info.value) + assert "HTTP_PROXY" in error_msg + assert "HTTPS_PROXY" in error_msg + + def test_creates_env_file_if_missing(self) -> None: + assert not GLOBAL_ENV_FILE.path.exists() + + set_proxy_var("HTTP_PROXY", "http://proxy:8080") + + assert GLOBAL_ENV_FILE.path.exists() + + def test_overwrites_existing_value(self) -> None: + set_proxy_var("HTTP_PROXY", "http://old:8080") + set_proxy_var("HTTP_PROXY", "http://new:8080") + + result = get_current_proxy_settings() + assert result["HTTP_PROXY"] == "http://new:8080" + + def test_preserves_other_values(self) -> None: + set_proxy_var("HTTP_PROXY", "http://proxy:8080") + set_proxy_var("HTTPS_PROXY", "https://proxy:8443") + + result = get_current_proxy_settings() + assert result["HTTP_PROXY"] == "http://proxy:8080" + assert result["HTTPS_PROXY"] == "https://proxy:8443" + + def test_handles_value_with_spaces(self) -> None: + set_proxy_var("NO_PROXY", "localhost, 127.0.0.1, .local") + + result = get_current_proxy_settings() + assert result["NO_PROXY"] == "localhost, 127.0.0.1, .local" + + def test_handles_url_with_credentials(self) -> None: + set_proxy_var("HTTP_PROXY", "http://user:password@proxy:8080") + + result = get_current_proxy_settings() + assert result["HTTP_PROXY"] == "http://user:password@proxy:8080" + + +class TestUnsetProxyVar: + def test_removes_existing_var(self) -> None: + set_proxy_var("HTTP_PROXY", "http://proxy:8080") + unset_proxy_var("HTTP_PROXY") + + result = get_current_proxy_settings() + assert result["HTTP_PROXY"] is None + + def test_uppercases_key_before_validation(self) -> None: + set_proxy_var("HTTP_PROXY", "http://proxy:8080") + unset_proxy_var("http_proxy") + + result = get_current_proxy_settings() + assert result["HTTP_PROXY"] is None + + def test_raises_error_for_unknown_key(self) -> None: + with pytest.raises(ProxySetupError) as exc_info: + unset_proxy_var("UNKNOWN_KEY") + + assert "Unknown key 'UNKNOWN_KEY'" in str(exc_info.value) + + def test_error_message_contains_supported_keys(self) -> None: + with pytest.raises(ProxySetupError) as exc_info: + unset_proxy_var("UNKNOWN_KEY") + + error_msg = str(exc_info.value) + assert "HTTP_PROXY" in error_msg + + def test_no_op_when_env_file_does_not_exist(self) -> None: + assert not GLOBAL_ENV_FILE.path.exists() + + unset_proxy_var("HTTP_PROXY") + + assert not GLOBAL_ENV_FILE.path.exists() + + def test_no_op_when_key_not_in_file(self) -> None: + set_proxy_var("HTTPS_PROXY", "https://proxy:8443") + unset_proxy_var("HTTP_PROXY") + + result = get_current_proxy_settings() + assert result["HTTP_PROXY"] is None + assert result["HTTPS_PROXY"] == "https://proxy:8443" + + def test_preserves_other_values(self) -> None: + set_proxy_var("HTTP_PROXY", "http://proxy:8080") + set_proxy_var("HTTPS_PROXY", "https://proxy:8443") + unset_proxy_var("HTTP_PROXY") + + result = get_current_proxy_settings() + assert result["HTTP_PROXY"] is None + assert result["HTTPS_PROXY"] == "https://proxy:8443" + + @pytest.mark.parametrize("key", SUPPORTED_PROXY_VARS.keys()) + def test_all_supported_vars_can_be_unset(self, key: str) -> None: + set_proxy_var(key, "test-value") + unset_proxy_var(key) + + result = get_current_proxy_settings() + assert result[key] is None + + +class TestParseProxyCommand: + def test_parses_key_only(self) -> None: + key, value = parse_proxy_command("HTTP_PROXY") + + assert key == "HTTP_PROXY" + assert value is None + + def test_parses_key_and_value(self) -> None: + key, value = parse_proxy_command("HTTP_PROXY http://proxy:8080") + + assert key == "HTTP_PROXY" + assert value == "http://proxy:8080" + + def test_uppercases_key(self) -> None: + key, value = parse_proxy_command("http_proxy") + + assert key == "HTTP_PROXY" + + def test_preserves_value_case(self) -> None: + key, value = parse_proxy_command("HTTP_PROXY http://Proxy:8080") + + assert value == "http://Proxy:8080" + + def test_strips_leading_whitespace(self) -> None: + key, value = parse_proxy_command(" HTTP_PROXY") + + assert key == "HTTP_PROXY" + + def test_strips_trailing_whitespace(self) -> None: + key, value = parse_proxy_command("HTTP_PROXY ") + + assert key == "HTTP_PROXY" + assert value is None + + def test_splits_on_first_space_only(self) -> None: + key, value = parse_proxy_command("NO_PROXY localhost, 127.0.0.1, .local") + + assert key == "NO_PROXY" + assert value == "localhost, 127.0.0.1, .local" + + def test_raises_error_for_empty_string(self) -> None: + with pytest.raises(ProxySetupError) as exc_info: + parse_proxy_command("") + + assert "No key provided" in str(exc_info.value) + + def test_raises_error_for_whitespace_only(self) -> None: + with pytest.raises(ProxySetupError) as exc_info: + parse_proxy_command(" ") + + assert "No key provided" in str(exc_info.value) + + def test_handles_tab_as_separator(self) -> None: + key, value = parse_proxy_command("HTTP_PROXY\thttp://proxy:8080") + + assert key == "HTTP_PROXY" + assert value == "http://proxy:8080" + + def test_handles_multiple_spaces_as_separator(self) -> None: + key, value = parse_proxy_command("HTTP_PROXY http://proxy:8080") + + assert key == "HTTP_PROXY" + assert value == "http://proxy:8080" diff --git a/tests/core/test_telemetry_send.py b/tests/core/test_telemetry_send.py new file mode 100644 index 0000000..fcb95c3 --- /dev/null +++ b/tests/core/test_telemetry_send.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.conftest import build_test_vibe_config +from tests.stubs.fake_tool import FakeTool, FakeToolArgs +from vibe.core.agent_loop import ToolDecision, ToolExecutionResponse +from vibe.core.config import Backend +from vibe.core.llm.format import ResolvedToolCall +from vibe.core.telemetry.send import DATALAKE_EVENTS_URL, TelemetryClient +from vibe.core.tools.base import BaseTool, ToolPermission +from vibe.core.utils import get_user_agent + +_original_send_telemetry_event = TelemetryClient.send_telemetry_event +from vibe.core.tools.builtins.write_file import WriteFile, WriteFileArgs + + +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) + ) + cls: type[BaseTool] = WriteFile + else: + validated = FakeToolArgs() + cls = FakeTool + return ResolvedToolCall( + tool_name=tool_name, tool_class=cls, validated_args=validated, call_id="call_1" + ) + + +def _run_telemetry_tasks() -> None: + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(asyncio.sleep(0)) + finally: + loop.close() + + +class TestTelemetryClient: + def test_send_telemetry_event_does_nothing_when_api_key_is_none( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + config = build_test_vibe_config(disable_telemetry=False) + env_key = config.get_provider_for_model( + config.get_active_model() + ).api_key_env_var + monkeypatch.delenv(env_key, raising=False) + client = TelemetryClient(config_getter=lambda: config) + assert client._get_mistral_api_key() is None + client._client = MagicMock() + client._client.post = AsyncMock() + + client.send_telemetry_event("vibe/test", {}) + _run_telemetry_tasks() + + client._client.post.assert_not_called() + + def test_send_telemetry_event_does_nothing_when_disabled( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + config = build_test_vibe_config(disable_telemetry=True) + env_key = config.get_provider_for_model( + config.get_active_model() + ).api_key_env_var + monkeypatch.setenv(env_key, "sk-test") + client = TelemetryClient(config_getter=lambda: config) + client._client = MagicMock() + client._client.post = AsyncMock() + + client.send_telemetry_event("vibe/test", {}) + _run_telemetry_tasks() + + client._client.post.assert_not_called() + + @pytest.mark.asyncio + async def test_send_telemetry_event_posts_when_enabled( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + TelemetryClient, "send_telemetry_event", _original_send_telemetry_event + ) + config = build_test_vibe_config(disable_telemetry=False) + env_key = config.get_provider_for_model( + config.get_active_model() + ).api_key_env_var + monkeypatch.setenv(env_key, "sk-test") + client = TelemetryClient(config_getter=lambda: config) + mock_post = AsyncMock(return_value=MagicMock(status_code=204)) + client._client = MagicMock() + client._client.post = mock_post + client._client.aclose = AsyncMock() + + client.send_telemetry_event("vibe/test_event", {"key": "value"}) + await client.aclose() + + mock_post.assert_called_once_with( + DATALAKE_EVENTS_URL, + json={"event": "vibe/test_event", "properties": {"key": "value"}}, + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer sk-test", + "User-Agent": get_user_agent(Backend.MISTRAL), + }, + ) + + def test_send_tool_call_finished_payload_shape( + self, telemetry_events: list[dict[str, Any]] + ) -> None: + config = build_test_vibe_config(disable_telemetry=False) + client = TelemetryClient(config_getter=lambda: config) + tool_call = _make_resolved_tool_call("todo", {}) + decision = ToolDecision( + verdict=ToolExecutionResponse.EXECUTE, approval_type=ToolPermission.ALWAYS + ) + + client.send_tool_call_finished( + tool_call=tool_call, + status="success", + decision=decision, + agent_profile_name="default", + ) + + assert len(telemetry_events) == 1 + event_name = telemetry_events[0]["event_name"] + assert event_name == "vibe/tool_call_finished" + properties = telemetry_events[0]["properties"] + assert properties["tool_name"] == "todo" + assert properties["status"] == "success" + assert properties["decision"] == "execute" + assert properties["approval_type"] == "always" + assert properties["agent_profile_name"] == "default" + assert properties["nb_files_created"] == 0 + assert properties["nb_files_modified"] == 0 + + def test_send_tool_call_finished_nb_files_created_write_file_new( + self, telemetry_events: list[dict[str, Any]] + ) -> None: + config = build_test_vibe_config(disable_telemetry=False) + client = TelemetryClient(config_getter=lambda: config) + tool_call = _make_resolved_tool_call("write_file", {"overwrite": False}) + + client.send_tool_call_finished( + tool_call=tool_call, + status="success", + decision=None, + agent_profile_name="default", + result={"file_existed": False}, + ) + + 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(disable_telemetry=False) + 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", + 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: + config = build_test_vibe_config(disable_telemetry=False) + client = TelemetryClient(config_getter=lambda: config) + tool_call = _make_resolved_tool_call("todo", {}) + + client.send_tool_call_finished( + tool_call=tool_call, + status="skipped", + decision=None, + agent_profile_name="default", + ) + + assert telemetry_events[0]["properties"]["decision"] is None + assert telemetry_events[0]["properties"]["approval_type"] is None + + def test_send_user_copied_text_payload( + self, telemetry_events: list[dict[str, Any]] + ) -> None: + config = build_test_vibe_config(disable_telemetry=False) + client = TelemetryClient(config_getter=lambda: config) + + client.send_user_copied_text("hello world") + + assert len(telemetry_events) == 1 + assert telemetry_events[0]["event_name"] == "vibe/user_copied_text" + assert telemetry_events[0]["properties"]["text_length"] == 11 + + def test_send_user_cancelled_action_payload( + self, telemetry_events: list[dict[str, Any]] + ) -> None: + config = build_test_vibe_config(disable_telemetry=False) + client = TelemetryClient(config_getter=lambda: config) + + client.send_user_cancelled_action("interrupt_agent") + + assert len(telemetry_events) == 1 + assert telemetry_events[0]["event_name"] == "vibe/user_cancelled_action" + assert telemetry_events[0]["properties"]["action"] == "interrupt_agent" + + def test_send_auto_compact_triggered_payload( + self, telemetry_events: list[dict[str, Any]] + ) -> None: + config = build_test_vibe_config(disable_telemetry=False) + client = TelemetryClient(config_getter=lambda: config) + + client.send_auto_compact_triggered() + + assert len(telemetry_events) == 1 + assert telemetry_events[0]["event_name"] == "vibe/auto_compact_triggered" + + def test_send_slash_command_used_payload( + self, telemetry_events: list[dict[str, Any]] + ) -> None: + config = build_test_vibe_config(disable_telemetry=False) + client = TelemetryClient(config_getter=lambda: config) + + client.send_slash_command_used("help", "builtin") + client.send_slash_command_used("my_skill", "skill") + + assert len(telemetry_events) == 2 + assert telemetry_events[0]["event_name"] == "vibe/slash_command_used" + assert telemetry_events[0]["properties"]["command"] == "help" + assert telemetry_events[0]["properties"]["command_type"] == "builtin" + assert telemetry_events[1]["properties"]["command"] == "my_skill" + assert telemetry_events[1]["properties"]["command_type"] == "skill" + + def test_send_new_session_payload( + self, telemetry_events: list[dict[str, Any]] + ) -> None: + config = build_test_vibe_config(disable_telemetry=False) + client = TelemetryClient(config_getter=lambda: config) + + client.send_new_session( + has_agents_md=True, + nb_skills=2, + nb_mcp_servers=1, + nb_models=3, + entrypoint="cli", + ) + + assert len(telemetry_events) == 1 + event_name = telemetry_events[0]["event_name"] + assert event_name == "vibe/new_session" + properties = telemetry_events[0]["properties"] + assert properties["has_agents_md"] is True + assert properties["nb_skills"] == 2 + assert properties["nb_mcp_servers"] == 1 + assert properties["nb_models"] == 3 + assert properties["entrypoint"] == "cli" + assert "version" in properties diff --git a/tests/core/test_trusted_folders.py b/tests/core/test_trusted_folders.py index 26583bd..be9447f 100644 --- a/tests/core/test_trusted_folders.py +++ b/tests/core/test_trusted_folders.py @@ -8,7 +8,12 @@ import pytest import tomli_w from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE -from vibe.core.trusted_folders import TrustedFoldersManager +from vibe.core.trusted_folders import ( + AGENTS_MD_FILENAMES, + TrustedFoldersManager, + has_agents_md_file, + has_trustable_content, +) class TestTrustedFoldersManager: @@ -203,3 +208,48 @@ class TestTrustedFoldersManager: manager.add_trusted(tmp_path) assert manager.is_trusted(tmp_path) is True + + +class TestHasAgentsMdFile: + def test_returns_false_for_empty_directory(self, tmp_path: Path) -> None: + assert has_agents_md_file(tmp_path) is False + + def test_returns_true_when_agents_md_exists(self, tmp_path: Path) -> None: + (tmp_path / "AGENTS.md").write_text("# Agents", encoding="utf-8") + assert has_agents_md_file(tmp_path) is True + + def test_returns_true_when_vibe_md_exists(self, tmp_path: Path) -> None: + (tmp_path / "VIBE.md").write_text("# Vibe", encoding="utf-8") + assert has_agents_md_file(tmp_path) is True + + def test_returns_true_when_dot_vibe_md_exists(self, tmp_path: Path) -> None: + (tmp_path / ".vibe.md").write_text("# Vibe", encoding="utf-8") + assert has_agents_md_file(tmp_path) is True + + def test_returns_false_when_only_other_files_exist(self, tmp_path: Path) -> None: + (tmp_path / "README.md").write_text("", encoding="utf-8") + (tmp_path / ".vibe").mkdir() + assert has_agents_md_file(tmp_path) is False + + def test_agents_md_filenames_constant(self) -> None: + assert AGENTS_MD_FILENAMES == ["AGENTS.md", "VIBE.md", ".vibe.md"] + + +class TestHasTrustableContent: + def test_returns_true_when_vibe_dir_exists(self, tmp_path: Path) -> None: + (tmp_path / ".vibe").mkdir() + assert has_trustable_content(tmp_path) is True + + def test_returns_true_when_agents_dir_exists(self, tmp_path: Path) -> None: + (tmp_path / ".agents").mkdir() + assert has_trustable_content(tmp_path) is True + + def test_returns_true_when_agents_md_filename_exists(self, tmp_path: Path) -> None: + for name in AGENTS_MD_FILENAMES: + (tmp_path / name).write_text("", encoding="utf-8") + assert has_trustable_content(tmp_path) is True + (tmp_path / name).unlink() + + def test_returns_false_when_no_trustable_content(self, tmp_path: Path) -> None: + (tmp_path / "other.txt").write_text("", encoding="utf-8") + assert has_trustable_content(tmp_path) is False diff --git a/tests/onboarding/test_ui_onboarding.py b/tests/onboarding/test_ui_onboarding.py index 5e7025a..379509b 100644 --- a/tests/onboarding/test_ui_onboarding.py +++ b/tests/onboarding/test_ui_onboarding.py @@ -41,7 +41,6 @@ async def test_ui_gets_through_the_onboarding_successfully() -> None: async with app.run_test() as pilot: await pass_welcome_screen(pilot) - api_screen = app.screen input_widget = api_screen.query_one("#key", Input) await pilot.press(*api_key_value) diff --git a/tests/session/test_session_loader.py b/tests/session/test_session_loader.py index cd886a1..f42754c 100644 --- a/tests/session/test_session_loader.py +++ b/tests/session/test_session_loader.py @@ -67,8 +67,8 @@ def create_test_session(): if metadata is None: metadata = { "session_id": session_id, - "start_time": "2023-01-01T12:00:00", - "end_time": "2023-01-01T12:05:00", + "start_time": "2023-01-01T12:00:00Z", + "end_time": "2023-01-01T12:05:00Z", "total_messages": 2, "stats": { "steps": 1, @@ -635,3 +635,190 @@ class TestSessionLoaderEdgeCases: assert messages[0].content == "Hello" assert messages[1].role == Role.assistant assert messages[1].content == "Hi there!" + + +@pytest.fixture +def create_test_session_with_cwd(): + def _create_session( + session_dir: Path, + session_id: str, + cwd: str, + title: str | None = None, + end_time: str | None = None, + ) -> Path: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + session_folder = session_dir / f"test_{timestamp}_{session_id[:8]}" + session_folder.mkdir(exist_ok=True) + + messages_file = session_folder / "messages.jsonl" + messages_file.write_text('{"role": "user", "content": "Hello"}\n') + + metadata = { + "session_id": session_id, + "start_time": "2024-01-01T12:00:00Z", + "end_time": end_time or "2024-01-01T12:05:00Z", + "environment": {"working_directory": cwd}, + "title": title, + } + + metadata_file = session_folder / "meta.json" + with metadata_file.open("w", encoding="utf-8") as f: + json.dump(metadata, f) + + return session_folder + + return _create_session + + +class TestSessionLoaderListSessions: + def test_list_sessions_empty(self, session_config: SessionLoggingConfig) -> None: + result = SessionLoader.list_sessions(session_config) + assert result == [] + + def test_list_sessions_returns_all_sessions( + self, session_config: SessionLoggingConfig, create_test_session_with_cwd + ) -> None: + session_dir = Path(session_config.save_dir) + + create_test_session_with_cwd( + session_dir, + "aaaaaaaa-1111", + "/home/user/project1", + title="First session", + end_time="2024-01-01T12:00:00Z", + ) + create_test_session_with_cwd( + session_dir, + "bbbbbbbb-2222", + "/home/user/project2", + title="Second session", + end_time="2024-01-01T13:00:00Z", + ) + + result = SessionLoader.list_sessions(session_config) + + assert len(result) == 2 + session_ids = {s["session_id"] for s in result} + assert "aaaaaaaa-1111" in session_ids + assert "bbbbbbbb-2222" in session_ids + + def test_list_sessions_filters_by_cwd( + self, session_config: SessionLoggingConfig, create_test_session_with_cwd + ) -> None: + session_dir = Path(session_config.save_dir) + + create_test_session_with_cwd( + session_dir, + "aaaaaaaa-proj1", + "/home/user/project1", + title="Project 1 session", + ) + create_test_session_with_cwd( + session_dir, + "bbbbbbbb-proj2", + "/home/user/project2", + title="Project 2 session", + ) + create_test_session_with_cwd( + session_dir, + "cccccccc-proj1", + "/home/user/project1", + title="Another Project 1 session", + ) + + result = SessionLoader.list_sessions(session_config, cwd="/home/user/project1") + + assert len(result) == 2 + for session in result: + assert session["cwd"] == "/home/user/project1" + + def test_list_sessions_includes_all_fields( + self, session_config: SessionLoggingConfig, create_test_session_with_cwd + ) -> None: + session_dir = Path(session_config.save_dir) + + create_test_session_with_cwd( + session_dir, + "test-session-123", + "/home/user/project", + title="Test Session Title", + end_time="2024-01-15T10:30:00Z", + ) + + result = SessionLoader.list_sessions(session_config) + + assert len(result) == 1 + session = result[0] + assert session["session_id"] == "test-session-123" + assert session["cwd"] == "/home/user/project" + assert session["title"] == "Test Session Title" + + def test_list_sessions_skips_invalid_sessions( + self, session_config: SessionLoggingConfig, create_test_session_with_cwd + ) -> None: + session_dir = Path(session_config.save_dir) + + create_test_session_with_cwd( + session_dir, "valid-se", "/home/user/project", title="Valid Session" + ) + + invalid_session = session_dir / "test_20240101_120000_invalid1" + invalid_session.mkdir() + (invalid_session / "meta.json").write_text('{"session_id": "invalid"}') + + no_id_session = session_dir / "test_20240101_120001_noid0000" + no_id_session.mkdir() + (no_id_session / "messages.jsonl").write_text( + '{"role": "user", "content": "Hello"}\n' + ) + (no_id_session / "meta.json").write_text( + '{"environment": {"working_directory": "/test"}}' + ) + + result = SessionLoader.list_sessions(session_config) + + assert len(result) == 1 + assert result[0]["session_id"] == "valid-se" + + def test_list_sessions_nonexistent_save_dir(self) -> None: + bad_config = SessionLoggingConfig( + save_dir="/nonexistent/path", session_prefix="test", enabled=True + ) + + result = SessionLoader.list_sessions(bad_config) + assert result == [] + + def test_list_sessions_handles_missing_environment( + self, session_config: SessionLoggingConfig + ) -> None: + session_dir = Path(session_config.save_dir) + + session_folder = session_dir / "test_20240101_120000_noenv000" + session_folder.mkdir() + (session_folder / "messages.jsonl").write_text( + '{"role": "user", "content": "Hello"}\n' + ) + (session_folder / "meta.json").write_text( + '{"session_id": "noenv000", "end_time": "2024-01-01T12:00:00Z"}' + ) + + result = SessionLoader.list_sessions(session_config) + + assert len(result) == 1 + assert result[0]["session_id"] == "noenv000" + assert result[0]["cwd"] == "" # Empty string when no working_directory + + def test_list_sessions_handles_none_title( + self, session_config: SessionLoggingConfig, create_test_session_with_cwd + ) -> None: + session_dir = Path(session_config.save_dir) + + create_test_session_with_cwd( + session_dir, "notitle0", "/home/user/project", title=None + ) + + result = SessionLoader.list_sessions(session_config) + + assert len(result) == 1 + assert result[0]["session_id"] == "notitle0" + assert result[0]["title"] is None diff --git a/tests/skills/test_manager.py b/tests/skills/test_manager.py index 68cf154..ec8f160 100644 --- a/tests/skills/test_manager.py +++ b/tests/skills/test_manager.py @@ -8,6 +8,7 @@ from tests.conftest import build_test_vibe_config from tests.skills.conftest import create_skill from vibe.core.config import VibeConfig from vibe.core.skills.manager import SkillManager +from vibe.core.trusted_folders import trusted_folders_manager @pytest.fixture @@ -184,6 +185,85 @@ class TestSkillManagerParsing: class TestSkillManagerSearchPaths: + def test_discovers_from_vibe_skills_when_cwd_trusted( + self, tmp_working_directory: Path + ) -> None: + trusted_folders_manager.add_trusted(tmp_working_directory) + vibe_skills = tmp_working_directory / ".vibe" / "skills" + vibe_skills.mkdir(parents=True) + create_skill(vibe_skills, "vibe-skill", "Skill from .vibe/skills") + + config = build_test_vibe_config( + system_prompt_id="tests", include_project_context=False, skill_paths=[] + ) + manager = SkillManager(lambda: config) + + assert "vibe-skill" in manager.available_skills + assert ( + manager.available_skills["vibe-skill"].description + == "Skill from .vibe/skills" + ) + + def test_discovers_from_agents_skills_when_cwd_trusted( + self, tmp_working_directory: Path + ) -> None: + trusted_folders_manager.add_trusted(tmp_working_directory) + agents_skills = tmp_working_directory / ".agents" / "skills" + agents_skills.mkdir(parents=True) + create_skill(agents_skills, "agents-skill", "Skill from .agents/skills") + + config = build_test_vibe_config( + system_prompt_id="tests", include_project_context=False, skill_paths=[] + ) + manager = SkillManager(lambda: config) + + assert "agents-skill" in manager.available_skills + assert ( + manager.available_skills["agents-skill"].description + == "Skill from .agents/skills" + ) + + def test_discovers_from_both_vibe_and_agents_skills_when_cwd_trusted( + self, tmp_working_directory: Path + ) -> None: + trusted_folders_manager.add_trusted(tmp_working_directory) + vibe_skills = tmp_working_directory / ".vibe" / "skills" + agents_skills = tmp_working_directory / ".agents" / "skills" + vibe_skills.mkdir(parents=True) + agents_skills.mkdir(parents=True) + create_skill(vibe_skills, "vibe-only", "From .vibe") + create_skill(agents_skills, "agents-only", "From .agents") + + config = build_test_vibe_config( + system_prompt_id="tests", include_project_context=False, skill_paths=[] + ) + manager = SkillManager(lambda: config) + + assert len(manager.available_skills) == 2 + assert manager.available_skills["vibe-only"].description == "From .vibe" + assert manager.available_skills["agents-only"].description == "From .agents" + + def test_first_discovered_wins_when_same_skill_in_vibe_and_agents( + self, tmp_working_directory: Path + ) -> None: + trusted_folders_manager.add_trusted(tmp_working_directory) + vibe_skills = tmp_working_directory / ".vibe" / "skills" + agents_skills = tmp_working_directory / ".agents" / "skills" + vibe_skills.mkdir(parents=True) + agents_skills.mkdir(parents=True) + create_skill(vibe_skills, "shared-skill", "First from .vibe") + create_skill(agents_skills, "shared-skill", "Second from .agents") + + config = build_test_vibe_config( + system_prompt_id="tests", include_project_context=False, skill_paths=[] + ) + manager = SkillManager(lambda: config) + + assert len(manager.available_skills) == 1 + assert ( + manager.available_skills["shared-skill"].description == "First from .vibe" + ) + def test_discovers_from_multiple_skill_paths(self, tmp_path: Path) -> None: # Create two separate skill directories skills_dir_1 = tmp_path / "skills1" diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg index 4f4b2d4..091ff60 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg @@ -111,7 +111,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg index 4407581..fac8b0e 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg @@ -141,7 +141,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg index 47e2747..cd36f74 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg @@ -160,7 +160,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg index 207a183..6ab81c7 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg @@ -164,7 +164,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg index eca1c2c..c5a10d5 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg @@ -160,7 +160,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg index b3d7e29..b423b09 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg @@ -160,7 +160,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg index 2690429..ab8d7a7 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg @@ -160,7 +160,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg index a2c12d0..0e30749 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg @@ -159,7 +159,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg index a2c12d0..0e30749 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg @@ -159,7 +159,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_cancel_discards_changes.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_cancel_discards_changes.svg new file mode 100644 index 0000000..b55c52b --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_cancel_discards_changes.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ProxySetupTestApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +Proxy setup opened... +Proxy setup cancelled. + + +┌──────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_edit_existing_values.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_edit_existing_values.svg new file mode 100644 index 0000000..a563885 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_edit_existing_values.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PrePopulatedProxySetupTestApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +Proxy setup opened... +Proxy settings saved. Restart the CLI for changes to take effect. + + +┌──────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_empty.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_empty.svg new file mode 100644 index 0000000..ed52ccb --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_empty.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ProxySetupTestApp + + + + + + + + + +  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +Proxy setup opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +Proxy Configuration + +HTTP_PROXYProxy URL for HTTP requests + +NOT SET + +HTTPS_PROXYProxy URL for HTTPS requests + +NOT SET + +ALL_PROXYProxy URL for all requests (fallback) + +NOT SET + +NO_PROXYComma-separated list of hosts to bypass proxy + +NOT SET + +SSL_CERT_FILEPath to custom SSL certificate file + +NOT SET + +SSL_CERT_DIRPath to directory containing SSL certificates + +NOT SET + +↑↓ navigate  Enter save & exit  ESC cancel +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_with_values.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_with_values.svg new file mode 100644 index 0000000..5b21d8a --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_with_values.svg @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PrePopulatedProxySetupTestApp + + + + + + + + + +  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +Proxy setup opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +Proxy Configuration + +HTTP_PROXYProxy URL for HTTP requests + +http://old-proxy:8080 + +HTTPS_PROXYProxy URL for HTTPS requests + +https://old-proxy:8443                                                                         + +ALL_PROXYProxy URL for all requests (fallback) + +NOT SET + +NO_PROXYComma-separated list of hosts to bypass proxy + +NOT SET + +SSL_CERT_FILEPath to custom SSL certificate file + +NOT SET + +SSL_CERT_DIRPath to directory containing SSL certificates + +NOT SET + +↑↓ navigate  Enter save & exit  ESC cancel +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_error.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_error.svg new file mode 100644 index 0000000..f34def2 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_error.svg @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ProxySetupTestApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +Proxy setup opened... +Error: Failed to save proxy settings: Permission denied + + +┌──────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_new_values.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_new_values.svg new file mode 100644 index 0000000..486e6f0 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_new_values.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ProxySetupTestApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +Proxy setup opened... +Proxy settings saved. Restart the CLI for changes to take effect. + + +┌──────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg index b935e92..f68abd5 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg @@ -162,7 +162,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg index 335de97..ab9fb7b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg @@ -162,7 +162,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg index b60a7cd..ce6ba06 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg @@ -162,7 +162,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg index 84fcc37..c54a163 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg @@ -162,7 +162,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg index 4036afb..a2d534b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg @@ -163,7 +163,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 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 d836ccf..a2d1c85 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 @@ -161,7 +161,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣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_no_plan_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg index ee2e31a..258f92f 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg @@ -161,7 +161,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣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_switch_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg index 48473d8..a804262 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 @@ -162,7 +162,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣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 5569165..2ddeacb 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 @@ -162,7 +162,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣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_whats_new_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg index ae1a597..3b5bd58 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg @@ -161,7 +161,7 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v2.1.0 · devstral-latest +   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/conftest.py b/tests/snapshots/conftest.py new file mode 100644 index 0000000..45f941a --- /dev/null +++ b/tests/snapshots/conftest.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def _pin_banner_version(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "vibe.cli.textual_ui.widgets.banner.banner.__version__", "0.0.0" + ) diff --git a/tests/snapshots/test_ui_snapshot_proxy_setup.py b/tests/snapshots/test_ui_snapshot_proxy_setup.py new file mode 100644 index 0000000..d9d0fd6 --- /dev/null +++ b/tests/snapshots/test_ui_snapshot_proxy_setup.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import pytest +from textual.pilot import Pilot + +from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp +from tests.snapshots.snap_compare import SnapCompare +from vibe.core.proxy_setup import get_current_proxy_settings, set_proxy_var + + +class ProxySetupTestApp(BaseSnapshotTestApp): + async def on_mount(self) -> None: + await super().on_mount() + await self._switch_to_proxy_setup_app() + + +class PrePopulatedProxySetupTestApp(BaseSnapshotTestApp): + async def on_mount(self) -> None: + set_proxy_var("HTTP_PROXY", "http://old-proxy:8080") + set_proxy_var("HTTPS_PROXY", "https://old-proxy:8443") + await super().on_mount() + await self._switch_to_proxy_setup_app() + + +def test_snapshot_proxy_setup_initial_empty(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_proxy_setup.py:ProxySetupTestApp", + terminal_size=(100, 36), + run_before=run_before, + ) + + +def test_snapshot_proxy_setup_initial_with_values(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_proxy_setup.py:PrePopulatedProxySetupTestApp", + terminal_size=(100, 36), + run_before=run_before, + ) + + +def test_snapshot_proxy_setup_save_new_values(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + await pilot.press(*"http://proxy.example.com:8080") + await pilot.press("down") + await pilot.press(*"https://proxy.example.com:8443") + await pilot.pause(0.1) + await pilot.press("enter") + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_proxy_setup.py:ProxySetupTestApp", + terminal_size=(100, 36), + run_before=run_before, + ) + + settings = get_current_proxy_settings() + assert settings["HTTP_PROXY"] == "http://proxy.example.com:8080" + assert settings["HTTPS_PROXY"] == "https://proxy.example.com:8443" + + +def test_snapshot_proxy_setup_edit_existing_values(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + await pilot.press("ctrl+u") + await pilot.press(*"http://new-proxy:9090") + await pilot.press("down") + await pilot.press("ctrl+u") + await pilot.pause(0.1) + await pilot.press("enter") + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_proxy_setup.py:PrePopulatedProxySetupTestApp", + terminal_size=(100, 36), + run_before=run_before, + ) + + settings = get_current_proxy_settings() + assert settings["HTTP_PROXY"] == "http://new-proxy:9090" + assert settings["HTTPS_PROXY"] is None + + +def test_snapshot_proxy_setup_cancel_discards_changes( + snap_compare: SnapCompare, +) -> None: + + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + await pilot.press(*"http://should-not-save:8080") + await pilot.pause(0.1) + await pilot.press("escape") + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_proxy_setup.py:ProxySetupTestApp", + terminal_size=(100, 36), + run_before=run_before, + ) + + settings = get_current_proxy_settings() + assert settings["HTTP_PROXY"] is None + + +def test_snapshot_proxy_setup_save_error( + snap_compare: SnapCompare, monkeypatch: pytest.MonkeyPatch +) -> None: + def raise_error(*args, **kwargs): + raise OSError("Permission denied") + + monkeypatch.setattr("vibe.core.proxy_setup.set_key", raise_error) + + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + await pilot.press(*"http://proxy:8080") + await pilot.press("enter") + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_proxy_setup.py:ProxySetupTestApp", + terminal_size=(100, 36), + run_before=run_before, + ) diff --git a/tests/test_agent_auto_compact.py b/tests/test_agent_auto_compact.py index dabdfb2..dfa7c04 100644 --- a/tests/test_agent_auto_compact.py +++ b/tests/test_agent_auto_compact.py @@ -16,7 +16,9 @@ from vibe.core.types import ( @pytest.mark.asyncio -async def test_auto_compact_triggers_and_batches_observer() -> None: +async def test_auto_compact_triggers_and_batches_observer( + telemetry_events: list[dict], +) -> None: observed: list[tuple[Role, str | None]] = [] def observer(msg: LLMMessage) -> None: @@ -52,3 +54,10 @@ async def test_auto_compact_triggers_and_batches_observer() -> None: assert roles == [Role.system, Role.user, Role.assistant] assert observed[1][1] is not None and "" in observed[1][1] assert observed[2][1] == "" + + auto_compact = [ + e + for e in telemetry_events + if e.get("event_name") == "vibe/auto_compact_triggered" + ] + assert len(auto_compact) == 1 diff --git a/tests/test_agent_observer_streaming.py b/tests/test_agent_observer_streaming.py index 3cf0aff..7f3bed6 100644 --- a/tests/test_agent_observer_streaming.py +++ b/tests/test_agent_observer_streaming.py @@ -17,7 +17,6 @@ from vibe.core.llm.exceptions import BackendErrorBuilder from vibe.core.middleware import ( ConversationContext, MiddlewareAction, - MiddlewarePipeline, MiddlewareResult, ResetReason, ) @@ -48,9 +47,6 @@ class InjectBeforeMiddleware: action=MiddlewareAction.INJECT_MESSAGE, message=self.injected_message ) - async def after_turn(self, context: ConversationContext) -> MiddlewareResult: - return MiddlewareResult() - def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None: return None @@ -99,15 +95,17 @@ async def test_act_flushes_batched_messages_with_injection_middleware( async for _ in agent.act("How can you help?"): pass - assert len(observed) == 3 - assert [r for r, _ in observed] == [Role.system, Role.user, Role.assistant] + assert len(observed) == 4 + assert [r for r, _ in observed] == [ + Role.system, + Role.user, + Role.user, + Role.assistant, + ] assert observed[0][1] == "You are Vibe, a super useful programming assistant." - # injected content should be appended to the user's message before emission - assert ( - observed[1][1] - == f"How can you help?\n\n{InjectBeforeMiddleware.injected_message}" - ) - assert observed[2][1] == "I can write very efficient code." + assert observed[1][1] == "How can you help?" + assert observed[2][1] == InjectBeforeMiddleware.injected_message + assert observed[3][1] == "I can write very efficient code." @pytest.mark.asyncio @@ -318,19 +316,14 @@ async def test_act_merges_streamed_tool_call_arguments() -> None: @pytest.mark.asyncio async def test_act_handles_user_cancellation_during_streaming() -> None: - class CountingMiddleware(MiddlewarePipeline): + class CountingMiddleware: def __init__(self) -> None: self.before_calls = 0 - self.after_calls = 0 async def before_turn(self, context: ConversationContext) -> MiddlewareResult: self.before_calls += 1 return MiddlewareResult() - async def after_turn(self, context: ConversationContext) -> MiddlewareResult: - self.after_calls += 1 - return MiddlewareResult() - def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None: return None @@ -371,7 +364,6 @@ async def test_act_handles_user_cancellation_during_streaming() -> None: ToolResultEvent, ] assert middleware.before_calls == 1 - assert middleware.after_calls == 0 assert isinstance(events[-1], ToolResultEvent) assert events[-1].skipped is True assert events[-1].skip_reason is not None diff --git a/tests/test_agent_stats.py b/tests/test_agent_stats.py index bf9abd6..60b24a6 100644 --- a/tests/test_agent_stats.py +++ b/tests/test_agent_stats.py @@ -378,9 +378,7 @@ class TestReloadPreservesMessages: assert agent.messages[0].role == Role.system @pytest.mark.asyncio - async def test_reload_notifies_observer_with_all_messages( - self, observer_capture - ) -> None: + async def test_reload_does_not_reemit_to_observer(self, observer_capture) -> None: observed, observer = observer_capture backend = FakeBackend(mock_llm_chunk(content="Response")) agent = build_test_agent_loop( @@ -394,10 +392,7 @@ class TestReloadPreservesMessages: await agent.reload_with_initial_messages() - assert len(observed) == 3 - assert observed[0].role == Role.system - assert observed[1].role == Role.user - assert observed[2].role == Role.assistant + assert len(observed) == 0 class TestCompactStatsHandling: diff --git a/tests/test_agent_tool_call.py b/tests/test_agent_tool_call.py index 0749fba..ae44e23 100644 --- a/tests/test_agent_tool_call.py +++ b/tests/test_agent_tool_call.py @@ -75,7 +75,9 @@ def make_agent_loop( @pytest.mark.asyncio -async def test_single_tool_call_executes_under_auto_approve() -> None: +async def test_single_tool_call_executes_under_auto_approve( + telemetry_events: list[dict], +) -> None: mocked_tool_call_id = "call_1" tool_call = make_todo_tool_call(mocked_tool_call_id) backend = FakeBackend([ @@ -110,9 +112,19 @@ async def test_single_tool_call_executes_under_auto_approve() -> None: assert tool_msgs[-1].tool_call_id == mocked_tool_call_id assert "total_count" in (tool_msgs[-1].content or "") + tool_finished = [ + e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished" + ] + assert len(tool_finished) == 1 + assert tool_finished[0]["properties"]["tool_name"] == "todo" + assert tool_finished[0]["properties"]["status"] == "success" + assert tool_finished[0]["properties"]["approval_type"] == "always" + @pytest.mark.asyncio -async def test_tool_call_requires_approval_if_not_auto_approved() -> None: +async def test_tool_call_requires_approval_if_not_auto_approved( + telemetry_events: list[dict], +) -> None: agent_loop = make_agent_loop( auto_approve=False, todo_permission=ToolPermission.ASK, @@ -145,9 +157,15 @@ async def test_tool_call_requires_approval_if_not_auto_approved() -> None: assert agent_loop.stats.tool_calls_agreed == 0 assert agent_loop.stats.tool_calls_succeeded == 0 + tool_finished = [ + e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished" + ] + assert len(tool_finished) == 1 + assert tool_finished[0]["properties"]["approval_type"] == "ask" + @pytest.mark.asyncio -async def test_tool_call_approved_by_callback() -> None: +async def test_tool_call_approved_by_callback(telemetry_events: list[dict]) -> None: def approval_callback( _tool_name: str, _args: BaseModel, _tool_call_id: str ) -> tuple[ApprovalResponse, str | None]: @@ -179,11 +197,17 @@ async def test_tool_call_approved_by_callback() -> None: assert agent_loop.stats.tool_calls_rejected == 0 assert agent_loop.stats.tool_calls_succeeded == 1 + tool_finished = [ + e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished" + ] + assert len(tool_finished) == 1 + assert tool_finished[0]["properties"]["approval_type"] == "ask" + @pytest.mark.asyncio -async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_callback() -> ( - None -): +async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_callback( + telemetry_events: list[dict], +) -> None: custom_feedback = "User declined tool execution" def approval_callback( @@ -218,9 +242,17 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal assert agent_loop.stats.tool_calls_agreed == 0 assert agent_loop.stats.tool_calls_succeeded == 0 + tool_finished = [ + e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished" + ] + assert len(tool_finished) == 1 + assert tool_finished[0]["properties"]["approval_type"] == "ask" + @pytest.mark.asyncio -async def test_tool_call_skipped_when_permission_is_never() -> None: +async def test_tool_call_skipped_when_permission_is_never( + telemetry_events: list[dict], +) -> None: agent_loop = make_agent_loop( auto_approve=False, todo_permission=ToolPermission.NEVER, @@ -254,6 +286,12 @@ async def test_tool_call_skipped_when_permission_is_never() -> None: assert agent_loop.stats.tool_calls_agreed == 0 assert agent_loop.stats.tool_calls_succeeded == 0 + tool_finished = [ + e for e in telemetry_events if e.get("event_name") == "vibe/tool_call_finished" + ] + assert len(tool_finished) == 1 + assert tool_finished[0]["properties"]["approval_type"] == "never" + @pytest.mark.asyncio async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> None: diff --git a/tests/test_cli_programmatic_preload.py b/tests/test_cli_programmatic_preload.py index ad332bf..34d4568 100644 --- a/tests/test_cli_programmatic_preload.py +++ b/tests/test_cli_programmatic_preload.py @@ -26,7 +26,7 @@ class SpyStreamingFormatter: def test_run_programmatic_preload_streaming_is_batched( - monkeypatch: pytest.MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, telemetry_events: list[dict] ) -> None: spy = SpyStreamingFormatter() monkeypatch.setattr( @@ -77,6 +77,14 @@ def test_run_programmatic_preload_streaming_is_batched( Role.user, Role.assistant, ] + + new_session = [ + e for e in telemetry_events if e.get("event_name") == "vibe/new_session" + ] + assert len(new_session) == 1 + assert new_session[0]["properties"]["entrypoint"] == "programmatic" + assert "version" in new_session[0]["properties"] + assert ( spy.emitted[0][1] == "You are Vibe, a super useful programming assistant." ) diff --git a/tests/test_message_merging.py b/tests/test_message_merging.py new file mode 100644 index 0000000..888b6d4 --- /dev/null +++ b/tests/test_message_merging.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from vibe.core.llm.message_utils import merge_consecutive_user_messages +from vibe.core.types import LLMMessage, Role + + +def test_merge_consecutive_user_messages() -> None: + messages = [ + LLMMessage(role=Role.system, content="System"), + LLMMessage(role=Role.user, content="User 1"), + LLMMessage(role=Role.user, content="User 2"), + LLMMessage(role=Role.assistant, content="Assistant"), + ] + result = merge_consecutive_user_messages(messages) + assert len(result) == 3 + assert result[1].content == "User 1\n\nUser 2" + + +def test_preserves_non_consecutive_user_messages() -> None: + messages = [ + LLMMessage(role=Role.user, content="User 1"), + LLMMessage(role=Role.assistant, content="Assistant"), + LLMMessage(role=Role.user, content="User 2"), + ] + result = merge_consecutive_user_messages(messages) + assert len(result) == 3 + + +def test_empty_messages() -> None: + assert merge_consecutive_user_messages([]) == [] + + +def test_single_message() -> None: + messages = [LLMMessage(role=Role.user, content="Only one")] + result = merge_consecutive_user_messages(messages) + assert len(result) == 1 + + +def test_three_consecutive_user_messages() -> None: + messages = [ + LLMMessage(role=Role.user, content="A"), + LLMMessage(role=Role.user, content="B"), + LLMMessage(role=Role.user, content="C"), + ] + result = merge_consecutive_user_messages(messages) + assert len(result) == 1 + assert result[0].content == "A\n\nB\n\nC" diff --git a/tests/test_middleware.py b/tests/test_middleware.py index f2a4ddc..8085395 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -2,14 +2,17 @@ from __future__ import annotations import pytest +from tests.conftest import build_test_agent_loop, build_test_vibe_config from vibe.core.agents.models import BUILTIN_AGENTS, AgentProfile, BuiltinAgentName from vibe.core.config import VibeConfig from vibe.core.middleware import ( + PLAN_AGENT_EXIT, PLAN_AGENT_REMINDER, ConversationContext, MiddlewareAction, MiddlewarePipeline, PlanAgentMiddleware, + ResetReason, ) from vibe.core.types import AgentStats @@ -71,28 +74,58 @@ class TestPlanAgentMiddleware: assert result.message is None @pytest.mark.asyncio - async def test_after_turn_always_continues(self, ctx: ConversationContext) -> None: + async def test_injects_reminder_only_once_while_in_plan_mode( + self, ctx: ConversationContext + ) -> None: middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN]) - result = await middleware.after_turn(ctx) + result1 = await middleware.before_turn(ctx) + assert result1.action == MiddlewareAction.INJECT_MESSAGE + assert result1.message == PLAN_AGENT_REMINDER - assert result.action == MiddlewareAction.CONTINUE + result2 = await middleware.before_turn(ctx) + assert result2.action == MiddlewareAction.CONTINUE + assert result2.message is None @pytest.mark.asyncio - async def test_dynamically_checks_agent(self, ctx: ConversationContext) -> None: - current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT] + async def test_injects_exit_message_when_leaving_plan_mode( + self, ctx: ConversationContext + ) -> None: + current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] middleware = PlanAgentMiddleware(lambda: current_profile) - result = await middleware.before_turn(ctx) - assert result.action == MiddlewareAction.CONTINUE + # Enter plan mode + await middleware.before_turn(ctx) - current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] + # Leave plan mode + current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT] result = await middleware.before_turn(ctx) assert result.action == MiddlewareAction.INJECT_MESSAGE + assert result.message == PLAN_AGENT_EXIT - current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE] - result = await middleware.before_turn(ctx) - assert result.action == MiddlewareAction.CONTINUE + @pytest.mark.asyncio + async def test_reinjects_reminder_when_reentering_plan_mode( + self, ctx: ConversationContext + ) -> None: + current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] + middleware = PlanAgentMiddleware(lambda: current_profile) + + # Enter plan mode - should inject reminder + result1 = await middleware.before_turn(ctx) + assert result1.action == MiddlewareAction.INJECT_MESSAGE + assert result1.message == PLAN_AGENT_REMINDER + + # Leave plan mode - should inject exit message + current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT] + result2 = await middleware.before_turn(ctx) + assert result2.action == MiddlewareAction.INJECT_MESSAGE + assert result2.message == PLAN_AGENT_EXIT + + # Re-enter plan mode - should inject reminder again + current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] + result3 = await middleware.before_turn(ctx) + assert result3.action == MiddlewareAction.INJECT_MESSAGE + assert result3.message == PLAN_AGENT_REMINDER @pytest.mark.asyncio async def test_custom_reminder(self, ctx: ConversationContext) -> None: @@ -105,10 +138,238 @@ class TestPlanAgentMiddleware: assert result.message == custom_reminder - def test_reset_does_nothing(self) -> None: + @pytest.mark.asyncio + async def test_reset_clears_state(self, ctx: ConversationContext) -> None: middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN]) + await middleware.before_turn(ctx) # Enter and inject + middleware.reset() + # Should inject again after reset + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.INJECT_MESSAGE + + @pytest.mark.asyncio + async def test_exit_message_fires_only_once(self, ctx: ConversationContext) -> None: + current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] + middleware = PlanAgentMiddleware(lambda: current_profile) + + # Enter plan mode + await middleware.before_turn(ctx) + + # Leave plan mode - first call should inject exit + current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT] + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.INJECT_MESSAGE + assert result.message == PLAN_AGENT_EXIT + + # Subsequent calls in default mode should be CONTINUE + result2 = await middleware.before_turn(ctx) + assert result2.action == MiddlewareAction.CONTINUE + assert result2.message is None + + @pytest.mark.asyncio + async def test_multiple_turns_in_plan_mode_after_entry( + self, ctx: ConversationContext + ) -> None: + middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN]) + + # First turn: inject reminder + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.INJECT_MESSAGE + + # Several more turns in plan mode: all should be CONTINUE + for _ in range(5): + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.CONTINUE + assert result.message is None + + @pytest.mark.asyncio + async def test_multiple_turns_in_default_mode_after_exit( + self, ctx: ConversationContext + ) -> None: + current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] + middleware = PlanAgentMiddleware(lambda: current_profile) + + await middleware.before_turn(ctx) # enter plan + + current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT] + await middleware.before_turn(ctx) # exit plan (fires exit message) + + # Several more turns in default mode: all should be CONTINUE + for _ in range(5): + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.CONTINUE + assert result.message is None + + @pytest.mark.asyncio + async def test_rapid_toggling_plan_default_multiple_cycles( + self, ctx: ConversationContext + ) -> None: + current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] + middleware = PlanAgentMiddleware(lambda: current_profile) + + for _ in range(3): + # Enter plan + current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.INJECT_MESSAGE + assert result.message == PLAN_AGENT_REMINDER + + # Leave plan + current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT] + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.INJECT_MESSAGE + assert result.message == PLAN_AGENT_EXIT + + @pytest.mark.asyncio + async def test_exit_to_non_default_agent(self, ctx: ConversationContext) -> None: + current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] + middleware = PlanAgentMiddleware(lambda: current_profile) + + await middleware.before_turn(ctx) # enter plan + + # Switch to auto_approve (not default) + current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE] + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.INJECT_MESSAGE + assert result.message == PLAN_AGENT_EXIT + + @pytest.mark.asyncio + async def test_exit_to_accept_edits_agent(self, ctx: ConversationContext) -> None: + current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] + middleware = PlanAgentMiddleware(lambda: current_profile) + + await middleware.before_turn(ctx) # enter plan + + # Switch to accept_edits + current_profile = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS] + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.INJECT_MESSAGE + assert result.message == PLAN_AGENT_EXIT + + @pytest.mark.asyncio + async def test_switching_between_non_plan_agents( + self, ctx: ConversationContext + ) -> None: + current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT] + middleware = PlanAgentMiddleware(lambda: current_profile) + + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.CONTINUE + + current_profile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE] + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.CONTINUE + + current_profile = BUILTIN_AGENTS[BuiltinAgentName.ACCEPT_EDITS] + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.CONTINUE + + @pytest.mark.asyncio + async def test_non_plan_to_plan_entry(self, ctx: ConversationContext) -> None: + """Starting in a non-plan agent then entering plan should inject reminder.""" + current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE] + middleware = PlanAgentMiddleware(lambda: current_profile) + + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.CONTINUE + + # Now switch to plan + current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.INJECT_MESSAGE + assert result.message == PLAN_AGENT_REMINDER + + @pytest.mark.asyncio + async def test_reset_while_in_default_after_exiting_plan( + self, ctx: ConversationContext + ) -> None: + current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] + middleware = PlanAgentMiddleware(lambda: current_profile) + + await middleware.before_turn(ctx) # enter plan + current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT] + await middleware.before_turn(ctx) # exit plan + + middleware.reset() + + # Still in default mode - should CONTINUE (no phantom exit message) + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.CONTINUE + + @pytest.mark.asyncio + async def test_reset_while_in_default_then_reenter_plan( + self, ctx: ConversationContext + ) -> None: + current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] + middleware = PlanAgentMiddleware(lambda: current_profile) + + await middleware.before_turn(ctx) # enter plan + current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT] + await middleware.before_turn(ctx) # exit plan + + middleware.reset() + + # Re-enter plan after reset + current_profile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.INJECT_MESSAGE + assert result.message == PLAN_AGENT_REMINDER + + @pytest.mark.asyncio + async def test_reset_with_compact_reason(self, ctx: ConversationContext) -> None: + middleware = PlanAgentMiddleware(lambda: BUILTIN_AGENTS[BuiltinAgentName.PLAN]) + await middleware.before_turn(ctx) # enter and inject + + middleware.reset(ResetReason.COMPACT) + + # Should reinject reminder after compact reset + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.INJECT_MESSAGE + assert result.message == PLAN_AGENT_REMINDER + + @pytest.mark.asyncio + async def test_custom_exit_message(self, ctx: ConversationContext) -> None: + custom_exit = "Custom exit message" + current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] + middleware = PlanAgentMiddleware( + lambda: current_profile, exit_message=custom_exit + ) + + await middleware.before_turn(ctx) # enter plan + + current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT] + result = await middleware.before_turn(ctx) + assert result.message == custom_exit + + @pytest.mark.asyncio + async def test_plan_entry_then_immediate_exit_same_not_possible( + self, ctx: ConversationContext + ) -> None: + """Even if profile changes between two calls, each call sees one transition.""" + current_profile: AgentProfile = BUILTIN_AGENTS[BuiltinAgentName.PLAN] + middleware = PlanAgentMiddleware(lambda: current_profile) + + # First call: entry + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.INJECT_MESSAGE + assert result.message == PLAN_AGENT_REMINDER + + # Second call (still plan): no injection + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.CONTINUE + + # Third call (switched to default): exit + current_profile = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT] + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.INJECT_MESSAGE + assert result.message == PLAN_AGENT_EXIT + + # Fourth call (still default): no injection + result = await middleware.before_turn(ctx) + assert result.action == MiddlewareAction.CONTINUE + class TestMiddlewarePipelineWithPlanAgent: @pytest.mark.asyncio @@ -135,3 +396,222 @@ class TestMiddlewarePipelineWithPlanAgent: result = await pipeline.run_before_turn(ctx) assert result.action == MiddlewareAction.CONTINUE + + +class TestPlanAgentMiddlewareIntegration: + @pytest.mark.asyncio + async def test_switch_agent_preserves_middleware_state_for_exit_message( + self, + ) -> None: + config = build_test_vibe_config( + auto_compact_threshold=0, + system_prompt_id="tests", + include_project_context=False, + include_prompt_detail=False, + include_model_info=False, + include_commit_signature=False, + enabled_tools=[], + ) + agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN) + + plan_middleware = next( + mw + for mw in agent.middleware_pipeline.middlewares + if isinstance(mw, PlanAgentMiddleware) + ) + + ctx = ConversationContext( + messages=agent.messages, stats=agent.stats, config=agent.config + ) + result = await plan_middleware.before_turn(ctx) + assert result.action == MiddlewareAction.INJECT_MESSAGE + assert result.message == PLAN_AGENT_REMINDER + + await agent.switch_agent(BuiltinAgentName.DEFAULT) + + plan_middleware_after = next( + mw + for mw in agent.middleware_pipeline.middlewares + if isinstance(mw, PlanAgentMiddleware) + ) + assert plan_middleware is plan_middleware_after + + ctx = ConversationContext( + messages=agent.messages, stats=agent.stats, config=agent.config + ) + result = await plan_middleware_after.before_turn(ctx) + assert result.action == MiddlewareAction.INJECT_MESSAGE + assert result.message == PLAN_AGENT_EXIT + + @pytest.mark.asyncio + async def test_switch_agent_allows_reinjection_on_reentry(self) -> None: + config = build_test_vibe_config( + auto_compact_threshold=0, + system_prompt_id="tests", + include_project_context=False, + include_prompt_detail=False, + include_model_info=False, + include_commit_signature=False, + enabled_tools=[], + ) + agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN) + + plan_middleware = next( + mw + for mw in agent.middleware_pipeline.middlewares + if isinstance(mw, PlanAgentMiddleware) + ) + + ctx = ConversationContext( + messages=agent.messages, stats=agent.stats, config=agent.config + ) + await plan_middleware.before_turn(ctx) + + await agent.switch_agent(BuiltinAgentName.DEFAULT) + + ctx = ConversationContext( + messages=agent.messages, stats=agent.stats, config=agent.config + ) + result = await plan_middleware.before_turn(ctx) + assert result.message == PLAN_AGENT_EXIT + + await agent.switch_agent(BuiltinAgentName.PLAN) + + ctx = ConversationContext( + messages=agent.messages, stats=agent.stats, config=agent.config + ) + result = await plan_middleware.before_turn(ctx) + assert result.action == MiddlewareAction.INJECT_MESSAGE + assert result.message == PLAN_AGENT_REMINDER + + @pytest.mark.asyncio + async def test_switch_plan_to_auto_approve_fires_exit(self) -> None: + config = build_test_vibe_config( + auto_compact_threshold=0, + system_prompt_id="tests", + include_project_context=False, + include_prompt_detail=False, + include_model_info=False, + include_commit_signature=False, + enabled_tools=[], + ) + agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN) + + plan_middleware = next( + mw + for mw in agent.middleware_pipeline.middlewares + if isinstance(mw, PlanAgentMiddleware) + ) + + ctx = ConversationContext( + messages=agent.messages, stats=agent.stats, config=agent.config + ) + await plan_middleware.before_turn(ctx) # enter plan + + await agent.switch_agent(BuiltinAgentName.AUTO_APPROVE) + + ctx = ConversationContext( + messages=agent.messages, stats=agent.stats, config=agent.config + ) + result = await plan_middleware.before_turn(ctx) + assert result.action == MiddlewareAction.INJECT_MESSAGE + assert result.message == PLAN_AGENT_EXIT + + @pytest.mark.asyncio + async def test_switch_between_non_plan_agents_no_injection(self) -> None: + config = build_test_vibe_config( + auto_compact_threshold=0, + system_prompt_id="tests", + include_project_context=False, + include_prompt_detail=False, + include_model_info=False, + include_commit_signature=False, + enabled_tools=[], + ) + agent = build_test_agent_loop( + config=config, agent_name=BuiltinAgentName.DEFAULT + ) + + plan_middleware = next( + mw + for mw in agent.middleware_pipeline.middlewares + if isinstance(mw, PlanAgentMiddleware) + ) + + ctx = ConversationContext( + messages=agent.messages, stats=agent.stats, config=agent.config + ) + result = await plan_middleware.before_turn(ctx) + assert result.action == MiddlewareAction.CONTINUE + + await agent.switch_agent(BuiltinAgentName.AUTO_APPROVE) + + ctx = ConversationContext( + messages=agent.messages, stats=agent.stats, config=agent.config + ) + result = await plan_middleware.before_turn(ctx) + assert result.action == MiddlewareAction.CONTINUE + + @pytest.mark.asyncio + async def test_full_lifecycle_plan_default_plan_default(self) -> None: + """Integration test for a full plan -> default -> plan -> default cycle.""" + config = build_test_vibe_config( + auto_compact_threshold=0, + system_prompt_id="tests", + include_project_context=False, + include_prompt_detail=False, + include_model_info=False, + include_commit_signature=False, + enabled_tools=[], + ) + agent = build_test_agent_loop(config=config, agent_name=BuiltinAgentName.PLAN) + + plan_middleware = next( + mw + for mw in agent.middleware_pipeline.middlewares + if isinstance(mw, PlanAgentMiddleware) + ) + + def _ctx(): + return ConversationContext( + messages=agent.messages, stats=agent.stats, config=agent.config + ) + + # 1. Enter plan: inject reminder + r = await plan_middleware.before_turn(_ctx()) + assert r.action == MiddlewareAction.INJECT_MESSAGE + assert r.message == PLAN_AGENT_REMINDER + + # 2. Stay in plan: no injection + r = await plan_middleware.before_turn(_ctx()) + assert r.action == MiddlewareAction.CONTINUE + + # 3. Switch to default: inject exit + await agent.switch_agent(BuiltinAgentName.DEFAULT) + r = await plan_middleware.before_turn(_ctx()) + assert r.action == MiddlewareAction.INJECT_MESSAGE + assert r.message == PLAN_AGENT_EXIT + + # 4. Stay in default: no injection + r = await plan_middleware.before_turn(_ctx()) + assert r.action == MiddlewareAction.CONTINUE + + # 5. Switch back to plan: inject reminder again + await agent.switch_agent(BuiltinAgentName.PLAN) + r = await plan_middleware.before_turn(_ctx()) + assert r.action == MiddlewareAction.INJECT_MESSAGE + assert r.message == PLAN_AGENT_REMINDER + + # 6. Stay in plan: no injection + r = await plan_middleware.before_turn(_ctx()) + assert r.action == MiddlewareAction.CONTINUE + + # 7. Switch to default again: inject exit + await agent.switch_agent(BuiltinAgentName.DEFAULT) + r = await plan_middleware.before_turn(_ctx()) + assert r.action == MiddlewareAction.INJECT_MESSAGE + assert r.message == PLAN_AGENT_EXIT + + # 8. Stay in default: no injection + r = await plan_middleware.before_turn(_ctx()) + assert r.action == MiddlewareAction.CONTINUE diff --git a/tests/test_reasoning_content.py b/tests/test_reasoning_content.py index 59666bf..0fa4a08 100644 --- a/tests/test_reasoning_content.py +++ b/tests/test_reasoning_content.py @@ -267,6 +267,7 @@ class TestAPIToolFormatHandlerReasoningContent: mock_message.role = "assistant" mock_message.content = "The answer is 42." mock_message.reasoning_content = "Let me think..." + mock_message.reasoning_signature = None mock_message.tool_calls = None result = handler.process_api_response_message(mock_message) diff --git a/tests/tools/test_mcp.py b/tests/tools/test_mcp.py index 908b50f..56f24ba 100644 --- a/tests/tools/test_mcp.py +++ b/tests/tools/test_mcp.py @@ -1,6 +1,10 @@ from __future__ import annotations -from unittest.mock import MagicMock +import logging +import os +import threading +import time +from unittest.mock import MagicMock, patch from pydantic import ValidationError import pytest @@ -9,7 +13,9 @@ from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp from vibe.core.tools.mcp import ( MCPToolResult, RemoteTool, + _mcp_stderr_capture, _parse_call_result, + _stderr_logger_thread, create_mcp_http_proxy_tool_class, create_mcp_stdio_proxy_tool_class, ) @@ -121,6 +127,66 @@ class TestParseCallResult: assert result.text == "line1\nline2" +class TestMCPStderrCapture: + """Tests for _mcp_stderr_capture and _stderr_logger_thread.""" + + @pytest.mark.asyncio + async def test_mcp_stderr_capture_returns_writable_stream(self): + async with _mcp_stderr_capture() as stream: + assert stream is not None + assert callable(getattr(stream, "write", None)) + stream.write("test\n") + + def test_stderr_logger_thread_logs_decoded_lines(self): + r_fd, w_fd = os.pipe() + try: + vibe_logger = logging.getLogger("vibe") + with patch.object(vibe_logger, "debug") as debug_mock: + thread = threading.Thread( + target=_stderr_logger_thread, args=(r_fd,), daemon=True + ) + thread.start() + try: + w = os.fdopen(w_fd, "wb") + w_fd = -1 + w.write(b"hello stderr\n") + w.write(b"second line\n") + w.close() + w = None + finally: + time.sleep(0.05) + debug_mock.assert_any_call("[MCP stderr] hello stderr") + debug_mock.assert_any_call("[MCP stderr] second line") + finally: + if w_fd >= 0: + try: + os.close(w_fd) + except OSError: + pass + try: + os.close(r_fd) + except OSError: + pass + + @pytest.mark.asyncio + async def test_mcp_stderr_capture_logs_written_data(self): + vibe_logger = logging.getLogger("vibe") + with patch.object(vibe_logger, "debug") as debug_mock: + async with _mcp_stderr_capture() as stream: + stream.write("captured line\n") + time.sleep(0.05) + debug_mock.assert_called_with("[MCP stderr] captured line") + + @pytest.mark.asyncio + async def test_mcp_stderr_capture_ignores_empty_lines(self): + vibe_logger = logging.getLogger("vibe") + with patch.object(vibe_logger, "debug") as debug_mock: + async with _mcp_stderr_capture() as stream: + stream.write("\n\n") + time.sleep(0.05) + debug_mock.assert_not_called() + + class TestCreateMCPHttpProxyToolClass: def test_creates_tool_class_with_correct_name(self): remote = RemoteTool(name="my_tool", description="Test tool") diff --git a/uv.lock b/uv.lock index e89a217..81f1c30 100644 --- a/uv.lock +++ b/uv.lock @@ -366,6 +366,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/f9/9ff5a301459f804a885f237453ba81564bc6ee54740e9f2676c2642043f6/giturlparse-0.14.0-py2.py3-none-any.whl", hash = "sha256:04fd9c262ca9a4db86043d2ef32b2b90bfcbcdefc4f6a260fd9402127880931d", size = 16299, upload-time = "2025-10-22T09:21:10.818Z" }, ] +[[package]] +name = "google-auth" +version = "2.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -710,7 +724,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.1.0" +version = "2.2.0" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, @@ -718,6 +732,7 @@ dependencies = [ { name = "cryptography" }, { name = "gitpython" }, { name = "giturlparse" }, + { name = "google-auth" }, { name = "httpx" }, { name = "keyring" }, { name = "mcp" }, @@ -729,6 +744,7 @@ dependencies = [ { name = "pyperclip" }, { name = "python-dotenv" }, { name = "pyyaml" }, + { name = "requests" }, { name = "rich" }, { name = "textual" }, { name = "textual-speedups" }, @@ -763,9 +779,10 @@ dev = [ requires-dist = [ { name = "agent-client-protocol", specifier = "==0.8.0" }, { name = "anyio", specifier = ">=4.12.0" }, - { name = "cryptography", specifier = ">=44.0.0" }, + { name = "cryptography", specifier = ">=44.0.0,<=46.0.3" }, { name = "gitpython", specifier = ">=3.1.46" }, { name = "giturlparse", specifier = ">=0.14.0" }, + { name = "google-auth", specifier = ">=2.0.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "keyring", specifier = ">=25.6.0" }, { name = "mcp", specifier = ">=1.14.0" }, @@ -777,6 +794,7 @@ requires-dist = [ { name = "pyperclip", specifier = ">=1.11.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, + { name = "requests", specifier = ">=2.20.0" }, { name = "rich", specifier = ">=14.0.0" }, { name = "textual", specifier = ">=1.0.0" }, { name = "textual-speedups", specifier = ">=0.2.1" }, @@ -947,6 +965,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "2.23" @@ -1483,6 +1522,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "ruff" version = "0.14.7" diff --git a/vibe/__init__.py b/vibe/__init__.py index ce01ef9..0276a2b 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.1.0" +__version__ = "2.2.0" diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py index db853ff..75baab5 100644 --- a/vibe/acp/acp_agent_loop.py +++ b/vibe/acp/acp_agent_loop.py @@ -20,7 +20,7 @@ from acp import ( SetSessionModeResponse, run_agent, ) -from acp.helpers import ContentBlock, SessionUpdate +from acp.helpers import ContentBlock, SessionUpdate, update_available_commands from acp.schema import ( AgentCapabilities, AgentMessageChunk, @@ -28,6 +28,8 @@ from acp.schema import ( AllowedOutcome, AuthenticateResponse, AuthMethod, + AvailableCommand, + AvailableCommandInput, ClientCapabilities, ContentToolCallContent, ForkSessionResponse, @@ -38,6 +40,9 @@ from acp.schema import ( ModelInfo, PromptCapabilities, ResumeSessionResponse, + SessionCapabilities, + SessionInfo, + SessionListCapabilities, SessionModelState, SessionModeState, SseMcpServer, @@ -45,6 +50,7 @@ from acp.schema import ( TextResourceContents, ToolCallProgress, ToolCallUpdate, + UnstructuredCommandInput, UserMessageChunk, ) from pydantic import BaseModel, ConfigDict @@ -58,15 +64,33 @@ from vibe.acp.tools.session_update import ( from vibe.acp.utils import ( TOOL_OPTIONS, ToolOption, + create_assistant_message_replay, create_compact_end_session_update, create_compact_start_session_update, + create_reasoning_replay, + create_tool_call_replay, + create_tool_result_replay, + create_user_message_replay, get_all_acp_session_modes, + get_proxy_help_text, is_valid_acp_agent, ) from vibe.core.agent_loop import AgentLoop from vibe.core.agents.models import BuiltinAgentName from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt -from vibe.core.config import MissingAPIKeyError, VibeConfig, load_dotenv_values +from vibe.core.config import ( + MissingAPIKeyError, + SessionLoggingConfig, + VibeConfig, + load_dotenv_values, +) +from vibe.core.proxy_setup import ( + ProxySetupError, + parse_proxy_command, + set_proxy_var, + unset_proxy_var, +) +from vibe.core.session.session_loader import SessionLoader from vibe.core.tools.base import BaseToolConfig, ToolPermission from vibe.core.types import ( ApprovalResponse, @@ -74,7 +98,9 @@ from vibe.core.types import ( AsyncApprovalCallback, CompactEndEvent, CompactStartEvent, + LLMMessage, ReasoningEvent, + Role, ToolCallEvent, ToolResultEvent, ToolStreamEvent, @@ -150,10 +176,13 @@ class VibeAcpAgentLoop(AcpAgent): response = InitializeResponse( agent_capabilities=AgentCapabilities( - load_session=False, + load_session=True, prompt_capabilities=PromptCapabilities( audio=False, embedded_context=True, image=False ), + session_capabilities=SessionCapabilities( + list=SessionListCapabilities() + ), ), protocol_version=PROTOCOL_VERSION, agent_info=Implementation( @@ -171,6 +200,44 @@ class VibeAcpAgentLoop(AcpAgent): ) -> AuthenticateResponse | None: raise NotImplementedError("Not implemented yet") + def _load_config(self) -> VibeConfig: + try: + config = VibeConfig.load(disabled_tools=["ask_user_question"]) + config.tool_paths.extend(self._get_acp_tool_overrides()) + return config + except MissingAPIKeyError as e: + raise RequestError.auth_required({ + "message": "You must be authenticated before creating a session" + }) from e + + async def _create_acp_session( + self, session_id: str, agent_loop: AgentLoop + ) -> AcpSessionLoop: + session = AcpSessionLoop(id=session_id, agent_loop=agent_loop) + self.sessions[session.id] = session + + if not agent_loop.auto_approve: + agent_loop.set_approval_callback(self._create_approval_callback(session.id)) + + asyncio.create_task(self._send_available_commands(session.id)) + + return session + + def _build_session_model_state(self, agent_loop: AgentLoop) -> SessionModelState: + return SessionModelState( + current_model_id=agent_loop.config.active_model, + available_models=[ + ModelInfo(model_id=model.alias, name=model.alias) + for model in agent_loop.config.models + ], + ) + + def _build_session_mode_state(self, session: AcpSessionLoop) -> SessionModeState: + return SessionModeState( + current_mode_id=session.agent_loop.agent_profile.name, + available_modes=get_all_acp_session_modes(session.agent_loop.agent_manager), + ) + @override async def new_session( self, @@ -181,13 +248,7 @@ class VibeAcpAgentLoop(AcpAgent): load_dotenv_values() os.chdir(cwd) - try: - config = VibeConfig.load(disabled_tools=["ask_user_question"]) - config.tool_paths.extend(self._get_acp_tool_overrides()) - except MissingAPIKeyError as e: - raise RequestError.auth_required({ - "message": "You must be authenticated before creating a new session" - }) from e + config = self._load_config() agent_loop = AgentLoop( config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True @@ -196,29 +257,14 @@ class VibeAcpAgentLoop(AcpAgent): # We should just use agent_loop.session_id everywhere, but it can still change during # session lifetime (e.g. agent_loop.compact is called). # We should refactor agent_loop.session_id to make it immutable in ACP context. - session = AcpSessionLoop(id=agent_loop.session_id, agent_loop=agent_loop) - self.sessions[session.id] = session + session = await self._create_acp_session(agent_loop.session_id, agent_loop) + agent_loop.emit_new_session_telemetry("acp") - if not agent_loop.auto_approve: - agent_loop.set_approval_callback( - self._create_approval_callback(agent_loop.session_id) - ) - - response = NewSessionResponse( - session_id=agent_loop.session_id, - models=SessionModelState( - current_model_id=agent_loop.config.active_model, - available_models=[ - ModelInfo(model_id=model.alias, name=model.alias) - for model in agent_loop.config.models - ], - ), - modes=SessionModeState( - current_mode_id=session.agent_loop.agent_profile.name, - available_modes=get_all_acp_session_modes(agent_loop.agent_manager), - ), + return NewSessionResponse( + session_id=session.id, + models=self._build_session_model_state(agent_loop), + modes=self._build_session_mode_state(session), ) - return response def _get_acp_tool_overrides(self) -> list[Path]: overrides = ["todo"] @@ -293,6 +339,85 @@ class VibeAcpAgentLoop(AcpAgent): raise RequestError.invalid_params({"session": "Not found"}) return self.sessions[session_id] + async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None: + if not msg.tool_calls: + return + for tool_call in msg.tool_calls: + if tool_call.id and tool_call.function.name: + update = create_tool_call_replay( + tool_call.id, tool_call.function.name, tool_call.function.arguments + ) + await self.client.session_update(session_id=session_id, update=update) + + async def _replay_conversation_history( + self, session_id: str, messages: list[LLMMessage] + ) -> None: + for msg in messages: + if msg.role == Role.user: + update = create_user_message_replay(msg) + await self.client.session_update(session_id=session_id, update=update) + + elif msg.role == Role.assistant: + if text_update := create_assistant_message_replay(msg): + await self.client.session_update( + session_id=session_id, update=text_update + ) + if reasoning_update := create_reasoning_replay(msg): + await self.client.session_update( + session_id=session_id, update=reasoning_update + ) + await self._replay_tool_calls(session_id, msg) + + elif msg.role == Role.tool: + if result_update := create_tool_result_replay(msg): + await self.client.session_update( + session_id=session_id, update=result_update + ) + + async def _send_available_commands(self, session_id: str) -> None: + commands = [ + AvailableCommand( + name="proxy-setup", + description="Configure proxy and SSL certificate settings", + input=AvailableCommandInput( + root=UnstructuredCommandInput( + hint="KEY value to set, KEY to unset, or empty for help" + ) + ), + ) + ] + + update = update_available_commands(commands) + await self.client.session_update(session_id=session_id, update=update) + + async def _handle_proxy_setup_command( + self, session_id: str, text_prompt: str + ) -> PromptResponse: + args = text_prompt.strip()[len("/proxy-setup") :].strip() + + try: + if not args: + message = get_proxy_help_text() + else: + key, value = parse_proxy_command(args) + if value is not None: + set_proxy_var(key, value) + message = f"Set `{key}={value}` in ~/.vibe/.env\n\nPlease start a new chat for changes to take effect." + else: + unset_proxy_var(key) + message = f"Removed `{key}` from ~/.vibe/.env\n\nPlease start a new chat for changes to take effect." + except ProxySetupError as e: + message = f"Error: {e}" + + await self.client.session_update( + session_id=session_id, + update=AgentMessageChunk( + session_update="agent_message_chunk", + content=TextContentBlock(type="text", text=message), + ), + ) + return PromptResponse(stop_reason="end_turn") + @override async def load_session( self, @@ -301,7 +426,44 @@ class VibeAcpAgentLoop(AcpAgent): mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None, **kwargs: Any, ) -> LoadSessionResponse | None: - raise NotImplementedError() + load_dotenv_values() + os.chdir(cwd) + + config = self._load_config() + + session_dir = SessionLoader.find_session_by_id( + session_id, config.session_logging + ) + if session_dir is None: + raise RequestError.invalid_params({ + "session_id": f"Session not found: {session_id}" + }) + + try: + loaded_messages, _ = SessionLoader.load_session(session_dir) + except ValueError as e: + raise RequestError.invalid_params({ + "session_id": f"Failed to load session: {e}" + }) from e + + agent_loop = AgentLoop( + config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True + ) + + non_system_messages = [ + msg for msg in loaded_messages if msg.role != Role.system + ] + + agent_loop.messages.extend(non_system_messages) + + session = await self._create_acp_session(session_id, agent_loop) + + await self._replay_conversation_history(session_id, non_system_messages) + + return LoadSessionResponse( + models=self._build_session_model_state(agent_loop), + modes=self._build_session_mode_state(session), + ) @override async def set_session_mode( @@ -348,7 +510,27 @@ class VibeAcpAgentLoop(AcpAgent): async def list_sessions( self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any ) -> ListSessionsResponse: - raise NotImplementedError() + try: + config = VibeConfig.load() + session_logging_config = config.session_logging + except MissingAPIKeyError: + session_logging_config = SessionLoggingConfig() + + session_data = SessionLoader.list_sessions(session_logging_config, cwd=cwd) + + sessions = [ + SessionInfo( + session_id=s["session_id"], + cwd=s["cwd"], + title=s.get("title"), + updated_at=s.get("end_time"), + ) + for s in sorted( + session_data, key=lambda s: s.get("end_time") or "", reverse=True + ) + ] + + return ListSessionsResponse(sessions=sessions) @override async def prompt( @@ -363,6 +545,9 @@ class VibeAcpAgentLoop(AcpAgent): text_prompt = self._build_text_prompt(prompt) + if text_prompt.strip().lower().startswith("/proxy-setup"): + return await self._handle_proxy_setup_command(session_id, text_prompt) + temp_user_message_id: str | None = kwargs.get("messageId") async def agent_loop_task() -> None: diff --git a/vibe/acp/utils.py b/vibe/acp/utils.py index 8f37f8f..bac082d 100644 --- a/vibe/acp/utils.py +++ b/vibe/acp/utils.py @@ -4,16 +4,20 @@ from enum import StrEnum from typing import TYPE_CHECKING, Literal, cast from acp.schema import ( + AgentMessageChunk, + AgentThoughtChunk, ContentToolCallContent, PermissionOption, SessionMode, TextContentBlock, ToolCallProgress, ToolCallStart, + UserMessageChunk, ) from vibe.core.agents.models import AgentProfile, AgentType -from vibe.core.types import CompactEndEvent, CompactStartEvent +from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings +from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage from vibe.core.utils import compact_reduction_display if TYPE_CHECKING: @@ -111,3 +115,99 @@ def create_compact_end_session_update(event: CompactEndEvent) -> ToolCallProgres ) ], ) + + +def get_proxy_help_text() -> str: + lines = [ + "## Proxy Configuration", + "", + "Configure proxy and SSL settings for HTTP requests.", + "", + "### Usage:", + "- `/proxy-setup` - Show this help and current settings", + "- `/proxy-setup KEY value` - Set an environment variable", + "- `/proxy-setup KEY` - Remove an environment variable", + "", + "### Supported Variables:", + ] + + for key, description in SUPPORTED_PROXY_VARS.items(): + lines.append(f"- `{key}`: {description}") + + lines.extend(["", "### Current Settings:"]) + + current = get_current_proxy_settings() + any_set = False + for key, value in current.items(): + if value: + lines.append(f"- `{key}={value}`") + any_set = True + + if not any_set: + lines.append("- (none configured)") + + return "\n".join(lines) + + +def create_user_message_replay(msg: LLMMessage) -> UserMessageChunk: + content = msg.content if isinstance(msg.content, str) else "" + return UserMessageChunk( + session_update="user_message_chunk", + content=TextContentBlock(type="text", text=content), + field_meta={"messageId": msg.message_id} if msg.message_id else {}, + ) + + +def create_assistant_message_replay(msg: LLMMessage) -> AgentMessageChunk | None: + content = msg.content if isinstance(msg.content, str) else "" + if not content: + return None + + return AgentMessageChunk( + session_update="agent_message_chunk", + content=TextContentBlock(type="text", text=content), + field_meta={"messageId": msg.message_id} if msg.message_id else {}, + ) + + +def create_reasoning_replay(msg: LLMMessage) -> AgentThoughtChunk | None: + if not isinstance(msg.reasoning_content, str) or not msg.reasoning_content: + return None + + return AgentThoughtChunk( + session_update="agent_thought_chunk", + content=TextContentBlock(type="text", text=msg.reasoning_content), + field_meta={"messageId": msg.message_id} if msg.message_id else {}, + ) + + +def create_tool_call_replay( + tool_call_id: str, tool_name: str, arguments: str | None +) -> ToolCallStart: + return ToolCallStart( + session_update="tool_call", + title=tool_name, + tool_call_id=tool_call_id, + kind="other", + raw_input=arguments, + ) + + +def create_tool_result_replay(msg: LLMMessage) -> ToolCallProgress | None: + if not msg.tool_call_id: + return None + + content = msg.content if isinstance(msg.content, str) else "" + return ToolCallProgress( + session_update="tool_call_update", + tool_call_id=msg.tool_call_id, + status="completed", + raw_output=content, + content=[ + ContentToolCallContent( + type="content", content=TextContentBlock(type="text", text=content) + ) + ] + if content + else None, + ) diff --git a/vibe/cli/clipboard.py b/vibe/cli/clipboard.py index 95832b2..ad73b44 100644 --- a/vibe/cli/clipboard.py +++ b/vibe/cli/clipboard.py @@ -26,7 +26,7 @@ def _shorten_preview(texts: list[str]) -> str: return dense_text -def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None: +def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None: selected_texts = [] for widget in app.query("*"): @@ -48,7 +48,7 @@ def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None: selected_texts.append(selected_text) if not selected_texts: - return + return None combined_text = "\n".join(selected_texts) @@ -61,7 +61,9 @@ def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None: timeout=2, markup=False, ) + return combined_text except Exception: app.notify( "Failed to copy - clipboard not available", severity="warning", timeout=3 ) + return None diff --git a/vibe/cli/commands.py b/vibe/cli/commands.py index 34d8b86..c99f8fc 100644 --- a/vibe/cli/commands.py +++ b/vibe/cli/commands.py @@ -67,6 +67,11 @@ class CommandRegistry: description="Teleport session to Vibe Nuage", handler="_teleport_command", ), + "proxy-setup": Command( + aliases=frozenset(["/proxy-setup"]), + description="Configure proxy and SSL certificate settings", + handler="_show_proxy_setup", + ), } for command in excluded_commands: @@ -78,9 +83,12 @@ class CommandRegistry: self._alias_map[alias] = cmd_name def find_command(self, user_input: str) -> Command | None: - cmd_name = self._alias_map.get(user_input.lower().strip()) + cmd_name = self.get_command_name(user_input) return self.commands.get(cmd_name) if cmd_name else None + def get_command_name(self, user_input: str) -> str | None: + return self._alias_map.get(user_input.lower().strip()) + def get_help_text(self) -> str: lines: list[str] = [ "### Keyboard Shortcuts", diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index 447d51c..666ccf2 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -51,6 +51,7 @@ from vibe.cli.textual_ui.widgets.messages import ( ) from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.path_display import PathDisplay +from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp from vibe.cli.textual_ui.widgets.question_app import QuestionApp from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage @@ -127,6 +128,7 @@ class BottomApp(StrEnum): Approval = auto() Config = auto() Input = auto() + ProxySetup = auto() Question = auto() @@ -308,6 +310,7 @@ class VibeApp(App): # noqa: PLR0904 await self._resume_history_from_messages() await self._check_and_show_whats_new() self._schedule_update_notification() + self.agent_loop.emit_new_session_telemetry("cli") if self._initial_prompt or self._teleport_on_start: self.call_after_refresh(self._process_initial_prompt) @@ -422,6 +425,24 @@ class VibeApp(App): # noqa: PLR0904 await self._switch_to_input_app() + async def on_proxy_setup_app_proxy_setup_closed( + self, message: ProxySetupApp.ProxySetupClosed + ) -> None: + if message.error: + await self._mount_and_scroll( + ErrorMessage(f"Failed to save proxy settings: {message.error}") + ) + elif message.saved: + await self._mount_and_scroll( + UserCommandMessage( + "Proxy settings saved. Restart the CLI for changes to take effect." + ) + ) + else: + await self._mount_and_scroll(UserCommandMessage("Proxy setup cancelled.")) + + await self._switch_to_input_app() + async def on_compact_message_completed( self, message: CompactMessage.Completed ) -> None: @@ -449,6 +470,10 @@ class VibeApp(App): # noqa: PLR0904 async def _handle_command(self, user_input: str) -> bool: if command := self.commands.find_command(user_input): + if cmd_name := self.commands.get_command_name(user_input): + self.agent_loop.telemetry_client.send_slash_command_used( + cmd_name, "builtin" + ) await self._mount_and_scroll(UserMessage(user_input)) handler = getattr(self, command.handler) if asyncio.iscoroutinefunction(handler): @@ -479,6 +504,8 @@ class VibeApp(App): # noqa: PLR0904 if not skill_info: return False + self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill") + try: skill_content = skill_info.skill_path.read_text(encoding="utf-8") except OSError as e: @@ -823,6 +850,11 @@ class VibeApp(App): # noqa: PLR0904 return await self._switch_to_config_app() + async def _show_proxy_setup(self) -> None: + if self._current_bottom_app == BottomApp.ProxySetup: + return + await self._switch_to_proxy_setup_app() + async def _reload_config(self) -> None: try: self._windowing.reset() @@ -996,6 +1028,13 @@ class VibeApp(App): # noqa: PLR0904 await self._mount_and_scroll(UserCommandMessage("Configuration opened...")) await self._switch_from_input(ConfigApp(self.config)) + async def _switch_to_proxy_setup_app(self) -> None: + if self._current_bottom_app == BottomApp.ProxySetup: + return + + await self._mount_and_scroll(UserCommandMessage("Proxy setup opened...")) + await self._switch_from_input(ProxySetupApp()) + async def _switch_to_approval_app( self, tool_name: str, tool_args: BaseModel ) -> None: @@ -1020,6 +1059,7 @@ class VibeApp(App): # noqa: PLR0904 self._chat_input_container.display = True self._current_bottom_app = BottomApp.Input self.call_after_refresh(self._chat_input_container.focus_input) + self.call_after_refresh(self._scroll_to_bottom) def _focus_current_bottom_app(self) -> None: try: @@ -1028,6 +1068,8 @@ class VibeApp(App): # noqa: PLR0904 self.query_one(ChatInputContainer).focus_input() case BottomApp.Config: self.query_one(ConfigApp).focus() + case BottomApp.ProxySetup: + self.query_one(ProxySetupApp).focus() case BottomApp.Approval: self.query_one(ApprovalApp).focus() case BottomApp.Question: @@ -1037,34 +1079,66 @@ class VibeApp(App): # noqa: PLR0904 except Exception: pass + def _handle_config_app_escape(self) -> None: + try: + config_app = self.query_one(ConfigApp) + config_app.action_close() + except Exception: + pass + self._last_escape_time = None + + def _handle_approval_app_escape(self) -> None: + try: + approval_app = self.query_one(ApprovalApp) + approval_app.action_reject() + except Exception: + pass + self.agent_loop.telemetry_client.send_user_cancelled_action("reject_approval") + self._last_escape_time = None + + def _handle_question_app_escape(self) -> None: + try: + question_app = self.query_one(QuestionApp) + question_app.action_cancel() + except Exception: + pass + self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question") + self._last_escape_time = None + + def _handle_input_app_escape(self) -> None: + try: + input_widget = self.query_one(ChatInputContainer) + input_widget.value = "" + except Exception: + pass + self._last_escape_time = None + + def _handle_agent_running_escape(self) -> None: + self.agent_loop.telemetry_client.send_user_cancelled_action("interrupt_agent") + self.run_worker(self._interrupt_agent_loop(), exclusive=False) + def action_interrupt(self) -> None: current_time = time.monotonic() if self._current_bottom_app == BottomApp.Config: + self._handle_config_app_escape() + return + + if self._current_bottom_app == BottomApp.ProxySetup: try: - config_app = self.query_one(ConfigApp) - config_app.action_close() + proxy_setup_app = self.query_one(ProxySetupApp) + proxy_setup_app.action_close() except Exception: pass self._last_escape_time = None return if self._current_bottom_app == BottomApp.Approval: - try: - approval_app = self.query_one(ApprovalApp) - approval_app.action_reject() - except Exception: - pass - self._last_escape_time = None + self._handle_approval_app_escape() return if self._current_bottom_app == BottomApp.Question: - try: - question_app = self.query_one(QuestionApp) - question_app.action_cancel() - except Exception: - pass - self._last_escape_time = None + self._handle_question_app_escape() return if ( @@ -1072,17 +1146,11 @@ class VibeApp(App): # noqa: PLR0904 and self._last_escape_time is not None and (current_time - self._last_escape_time) < 0.2 # noqa: PLR2004 ): - try: - input_widget = self.query_one(ChatInputContainer) - if input_widget.value: - input_widget.value = "" - self._last_escape_time = None - return - except Exception: - pass + self._handle_input_app_escape() + return if self._agent_running: - self.run_worker(self._interrupt_agent_loop(), exclusive=False) + self._handle_agent_running_escape() self._last_escape_time = current_time self._scroll_to_bottom() @@ -1430,11 +1498,15 @@ class VibeApp(App): # noqa: PLR0904 ) def action_copy_selection(self) -> None: - copy_selection_to_clipboard(self, show_toast=False) + copied_text = copy_selection_to_clipboard(self, show_toast=False) + if copied_text is not None: + self.agent_loop.telemetry_client.send_user_copied_text(copied_text) def on_mouse_up(self, event: MouseUp) -> None: if self.config.autocopy_to_clipboard: - copy_selection_to_clipboard(self, show_toast=True) + copied_text = copy_selection_to_clipboard(self, show_toast=True) + if copied_text is not None: + self.agent_loop.telemetry_client.send_user_copied_text(copied_text) def on_app_blur(self, event: AppBlur) -> None: if self._chat_input_container and self._chat_input_container.input_widget: diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss index dc57a97..40d5f51 100644 --- a/vibe/cli/textual_ui/app.tcss +++ b/vibe/cli/textual_ui/app.tcss @@ -703,6 +703,44 @@ StatusMessage { color: ansi_bright_black; } +#proxysetup-app { + width: 100%; + height: auto; + background: transparent; + border: solid ansi_bright_black; + padding: 0 1; + margin: 0; +} + +#proxysetup-content { + width: 100%; + height: auto; +} + +.proxy-label { + height: auto; + color: ansi_blue; + text-style: bold; +} + +.proxy-description { + height: auto; + color: ansi_bright_black; +} + +.proxy-label-line { + height: auto; +} + +.proxy-input { + width: 100%; + height: auto; + border: none; + border-left: wide ansi_bright_black; + margin-top: 1; + padding: 0 0 0 1; +} + #approval-app { width: 100%; height: auto; diff --git a/vibe/cli/textual_ui/widgets/proxy_setup_app.py b/vibe/cli/textual_ui/widgets/proxy_setup_app.py new file mode 100644 index 0000000..baafba7 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/proxy_setup_app.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from typing import ClassVar + +from textual import events +from textual.app import ComposeResult +from textual.binding import Binding, BindingType +from textual.containers import Container, Vertical +from textual.message import Message +from textual.widgets import Input, Static + +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic +from vibe.core.proxy_setup import ( + SUPPORTED_PROXY_VARS, + get_current_proxy_settings, + set_proxy_var, + unset_proxy_var, +) + + +class ProxySetupApp(Container): + can_focus = True + can_focus_children = True + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("up", "focus_previous", "Up", show=False), + Binding("down", "focus_next", "Down", show=False), + ] + + class ProxySetupClosed(Message): + def __init__(self, saved: bool, error: str | None = None) -> None: + super().__init__() + self.saved = saved + self.error = error + + def __init__(self) -> None: + super().__init__(id="proxysetup-app") + self.inputs: dict[str, Input] = {} + self.initial_values: dict[str, str | None] = {} + + def compose(self) -> ComposeResult: + self.initial_values = get_current_proxy_settings() + + with Vertical(id="proxysetup-content"): + yield NoMarkupStatic("Proxy Configuration", classes="settings-title") + yield NoMarkupStatic("") + + for key, description in SUPPORTED_PROXY_VARS.items(): + yield Static( + f"[bold ansi_blue]{key}[/] [dim]{description}[/dim]", + classes="proxy-label-line", + ) + + initial_value = self.initial_values.get(key) or "" + input_widget = Input( + value=initial_value, + placeholder="NOT SET", + id=f"proxy-input-{key}", + classes="proxy-input", + ) + self.inputs[key] = input_widget + yield input_widget + + yield NoMarkupStatic("") + + yield NoMarkupStatic( + "↑↓ navigate Enter save & exit ESC cancel", classes="settings-help" + ) + + def focus(self, scroll_visible: bool = True) -> ProxySetupApp: + """Override focus to focus the first input widget.""" + if self.inputs: + first_input = list(self.inputs.values())[0] + first_input.focus(scroll_visible=scroll_visible) + else: + super().focus(scroll_visible=scroll_visible) + return self + + def action_focus_next(self) -> None: + inputs = list(self.inputs.values()) + focused = self.screen.focused + if focused is not None and isinstance(focused, Input) and focused in inputs: + idx = inputs.index(focused) + next_idx = (idx + 1) % len(inputs) + inputs[next_idx].focus() + + def action_focus_previous(self) -> None: + inputs = list(self.inputs.values()) + focused = self.screen.focused + if focused is not None and isinstance(focused, Input) and focused in inputs: + idx = inputs.index(focused) + prev_idx = (idx - 1) % len(inputs) + inputs[prev_idx].focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + self._save_and_close() + + def on_blur(self, _event: events.Blur) -> None: + self.call_after_refresh(self._refocus_if_needed) + + def on_input_blurred(self, _event: Input.Blurred) -> None: + self.call_after_refresh(self._refocus_if_needed) + + def _refocus_if_needed(self) -> None: + if self.has_focus or any(inp.has_focus for inp in self.inputs.values()): + return + self.focus() + + def _save_and_close(self) -> None: + try: + for key, input_widget in self.inputs.items(): + new_value = input_widget.value.strip() + old_value = self.initial_values.get(key) or "" + + if new_value != old_value: + if new_value: + set_proxy_var(key, new_value) + else: + unset_proxy_var(key) + except Exception as e: + self.post_message(self.ProxySetupClosed(saved=False, error=str(e))) + return + + self.post_message(self.ProxySetupClosed(saved=True)) + + def action_close(self) -> None: + self.post_message(self.ProxySetupClosed(saved=False)) diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index c13e097..8b30ef5 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -4,19 +4,26 @@ import asyncio from collections.abc import AsyncGenerator, Callable from enum import StrEnum, auto from http import HTTPStatus +import json +from pathlib import Path from threading import Thread import time -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, Literal, cast from uuid import uuid4 from pydantic import BaseModel from vibe.core.agents.manager import AgentManager from vibe.core.agents.models import AgentProfile, BuiltinAgentName -from vibe.core.config import VibeConfig +from vibe.core.config import Backend, ProviderConfig, VibeConfig from vibe.core.llm.backend.factory import BACKEND_FACTORY from vibe.core.llm.exceptions import BackendError -from vibe.core.llm.format import APIToolFormatHandler, ResolvedMessage, ResolvedToolCall +from vibe.core.llm.format import ( + APIToolFormatHandler, + FailedToolCall, + ResolvedMessage, + ResolvedToolCall, +) from vibe.core.llm.types import BackendLike from vibe.core.middleware import ( AutoCompactMiddleware, @@ -35,6 +42,7 @@ from vibe.core.session.session_logger import SessionLogger from vibe.core.session.session_migration import migrate_sessions_entrypoint from vibe.core.skills.manager import SkillManager from vibe.core.system_prompt import get_universal_system_prompt +from vibe.core.telemetry.send import TelemetryClient from vibe.core.tools.base import ( BaseTool, BaseToolConfig, @@ -44,6 +52,7 @@ from vibe.core.tools.base import ( ToolPermissionError, ) from vibe.core.tools.manager import ToolManager +from vibe.core.trusted_folders import has_agents_md_file from vibe.core.types import ( AgentStats, ApprovalCallback, @@ -96,6 +105,7 @@ class ToolExecutionResponse(StrEnum): class ToolDecision(BaseModel): verdict: ToolExecutionResponse + approval_type: ToolPermission feedback: str | None = None @@ -171,7 +181,9 @@ class AgentLoop: self.user_input_callback: UserInputCallback | None = None self.session_id = str(uuid4()) + self._current_user_message_id: str | None = None + self.telemetry_client = TelemetryClient(config_getter=lambda: self.config) self.session_logger = SessionLogger(config.session_logging, self.session_id) self._teleport_service: TeleportService | None = None @@ -209,6 +221,21 @@ class AgentLoop: self.config.tools[tool_name].permission = permission self.tool_manager.invalidate_tool(tool_name) + def emit_new_session_telemetry( + self, entrypoint: Literal["cli", "acp", "programmatic"] + ) -> None: + has_agents_md = has_agents_md_file(Path.cwd()) + nb_skills = len(self.skill_manager.available_skills) + nb_mcp_servers = len(self.config.mcp_servers) + nb_models = len(self.config.models) + self.telemetry_client.send_new_session( + has_agents_md=has_agents_md, + nb_skills=nb_skills, + nb_mcp_servers=nb_mcp_servers, + nb_models=nb_models, + entrypoint=entrypoint, + ) + def _select_backend(self) -> BackendLike: active_model = self.config.get_active_model() provider = self.config.get_provider_for_model(active_model) @@ -331,12 +358,11 @@ class AgentLoop: ) case MiddlewareAction.INJECT_MESSAGE: - if result.message and len(self.messages) > 0: - last_msg = self.messages[-1] - if last_msg.content: - last_msg.content += f"\n\n{result.message}" - else: - last_msg.content = result.message + if result.message: + injected_message = LLMMessage( + role=Role.user, content=result.message + ) + self.messages.append(injected_message) case MiddlewareAction.COMPACT: old_tokens = result.metadata.get( @@ -352,6 +378,7 @@ class AgentLoop: current_context_tokens=old_tokens, threshold=threshold, ) + self.telemetry_client.send_auto_compact_triggered() summary = await self.compact() @@ -370,10 +397,25 @@ class AgentLoop: messages=self.messages, stats=self.stats, config=self.config ) + def _get_extra_headers(self, provider: ProviderConfig) -> dict[str, str]: + headers: dict[str, str] = { + "user-agent": get_user_agent(provider.backend), + "x-affinity": self.session_id, + } + if ( + provider.backend == Backend.MISTRAL + and self._current_user_message_id is not None + ): + headers["metadata"] = json.dumps({ + "message_id": self._current_user_message_id + }) + return headers + async def _conversation_loop(self, user_msg: str) -> AsyncGenerator[BaseEvent]: user_message = LLMMessage(role=Role.user, content=user_msg) self.messages.append(user_message) self.stats.steps += 1 + self._current_user_message_id = user_message.message_id if user_message.message_id is None: raise AgentLoopError("User message must have a message_id") @@ -406,15 +448,6 @@ class AgentLoop: if user_cancelled: return - after_result = await self.middleware_pipeline.run_after_turn( - self._get_context() - ) - async for event in self._handle_middleware_result(after_result): - yield event - - if after_result.action == MiddlewareAction.STOP: - return - finally: await self._flush_new_messages() @@ -497,19 +530,17 @@ class AgentLoop: message_id=llm_result.message.message_id, ) - async def _handle_tool_calls( - self, resolved: ResolvedMessage - ) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]: - for failed in resolved.failed_calls: + async def _emit_failed_tool_events( + self, failed_calls: list[FailedToolCall] + ) -> AsyncGenerator[ToolResultEvent]: + for failed in failed_calls: error_msg = f"<{TOOL_ERROR_TAG}>{failed.tool_name}: {failed.error}" - yield ToolResultEvent( tool_name=failed.tool_name, tool_class=None, error=error_msg, tool_call_id=failed.call_id, ) - self.stats.tool_calls_failed += 1 self.messages.append( self.format_handler.create_failed_tool_response_message( @@ -517,6 +548,113 @@ class AgentLoop: ) ) + async def _process_one_tool_call( + self, tool_call: ResolvedToolCall + ) -> AsyncGenerator[ToolResultEvent | ToolStreamEvent]: + try: + tool_instance = self.tool_manager.get(tool_call.tool_name) + except Exception as exc: + error_msg = f"Error getting tool '{tool_call.tool_name}': {exc}" + yield ToolResultEvent( + tool_name=tool_call.tool_name, + tool_class=tool_call.tool_class, + error=error_msg, + tool_call_id=tool_call.call_id, + ) + self._handle_tool_response(tool_call, error_msg, "failure") + return + + decision = await self._should_execute_tool( + tool_instance, tool_call.validated_args, tool_call.call_id + ) + + if decision.verdict == ToolExecutionResponse.SKIP: + self.stats.tool_calls_rejected += 1 + skip_reason = decision.feedback or str( + get_user_cancellation_message( + CancellationReason.TOOL_SKIPPED, tool_call.tool_name + ) + ) + yield ToolResultEvent( + tool_name=tool_call.tool_name, + tool_class=tool_call.tool_class, + skipped=True, + skip_reason=skip_reason, + tool_call_id=tool_call.call_id, + ) + self._handle_tool_response(tool_call, skip_reason, "skipped", decision) + return + + self.stats.tool_calls_agreed += 1 + + try: + start_time = time.perf_counter() + result_model = None + async for item in tool_instance.invoke( + ctx=InvokeContext( + tool_call_id=tool_call.call_id, + approval_callback=self.approval_callback, + agent_manager=self.agent_manager, + user_input_callback=self.user_input_callback, + ), + **tool_call.args_dict, + ): + if isinstance(item, ToolStreamEvent): + yield item + else: + result_model = item + + duration = time.perf_counter() - start_time + if result_model is None: + raise ToolError("Tool did not yield a result") + + result_dict = result_model.model_dump() + text = "\n".join(f"{k}: {v}" for k, v in result_dict.items()) + self._handle_tool_response( + tool_call, text, "success", decision, result_dict + ) + yield ToolResultEvent( + tool_name=tool_call.tool_name, + tool_class=tool_call.tool_class, + result=result_model, + duration=duration, + tool_call_id=tool_call.call_id, + ) + self.stats.tool_calls_succeeded += 1 + + except asyncio.CancelledError: + cancel = str( + get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED) + ) + yield ToolResultEvent( + tool_name=tool_call.tool_name, + tool_class=tool_call.tool_class, + error=cancel, + tool_call_id=tool_call.call_id, + ) + self._handle_tool_response(tool_call, cancel, "failure", decision) + raise + + except (ToolError, ToolPermissionError) as exc: + error_msg = f"<{TOOL_ERROR_TAG}>{tool_instance.get_name()} failed: {exc}" + yield ToolResultEvent( + tool_name=tool_call.tool_name, + tool_class=tool_call.tool_class, + error=error_msg, + tool_call_id=tool_call.call_id, + ) + if isinstance(exc, ToolPermissionError): + self.stats.tool_calls_agreed -= 1 + self.stats.tool_calls_rejected += 1 + else: + self.stats.tool_calls_failed += 1 + self._handle_tool_response(tool_call, error_msg, "failure", decision) + + async def _handle_tool_calls( + self, resolved: ResolvedMessage + ) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]: + async for event in self._emit_failed_tool_events(resolved.failed_calls): + yield event for tool_call in resolved.tool_calls: yield ToolCallEvent( tool_name=tool_call.tool_name, @@ -524,120 +662,31 @@ class AgentLoop: args=tool_call.validated_args, tool_call_id=tool_call.call_id, ) + async for event in self._process_one_tool_call(tool_call): + yield event - try: - tool_instance = self.tool_manager.get(tool_call.tool_name) - except Exception as exc: - error_msg = f"Error getting tool '{tool_call.tool_name}': {exc}" - yield ToolResultEvent( - tool_name=tool_call.tool_name, - tool_class=tool_call.tool_class, - error=error_msg, - tool_call_id=tool_call.call_id, - ) - self._append_tool_response(tool_call, error_msg) - continue - - decision = await self._should_execute_tool( - tool_instance, tool_call.validated_args, tool_call.call_id - ) - - if decision.verdict == ToolExecutionResponse.SKIP: - self.stats.tool_calls_rejected += 1 - skip_reason = decision.feedback or str( - get_user_cancellation_message( - CancellationReason.TOOL_SKIPPED, tool_call.tool_name - ) - ) - - yield ToolResultEvent( - tool_name=tool_call.tool_name, - tool_class=tool_call.tool_class, - skipped=True, - skip_reason=skip_reason, - tool_call_id=tool_call.call_id, - ) - self._append_tool_response(tool_call, skip_reason) - continue - - self.stats.tool_calls_agreed += 1 - - try: - start_time = time.perf_counter() - result_model = None - - async for item in tool_instance.invoke( - ctx=InvokeContext( - tool_call_id=tool_call.call_id, - approval_callback=self.approval_callback, - agent_manager=self.agent_manager, - user_input_callback=self.user_input_callback, - ), - **tool_call.args_dict, - ): - if isinstance(item, ToolStreamEvent): - yield item - else: - result_model = item - - duration = time.perf_counter() - start_time - - if result_model is None: - raise ToolError("Tool did not yield a result") - - text = "\n".join( - f"{k}: {v}" for k, v in result_model.model_dump().items() - ) - self._append_tool_response(tool_call, text) - - yield ToolResultEvent( - tool_name=tool_call.tool_name, - tool_class=tool_call.tool_class, - result=result_model, - duration=duration, - tool_call_id=tool_call.call_id, - ) - - self.stats.tool_calls_succeeded += 1 - - except asyncio.CancelledError: - cancel = str( - get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED) - ) - yield ToolResultEvent( - tool_name=tool_call.tool_name, - tool_class=tool_call.tool_class, - error=cancel, - tool_call_id=tool_call.call_id, - ) - self._append_tool_response(tool_call, cancel) - raise - - except (ToolError, ToolPermissionError) as exc: - error_msg = f"<{TOOL_ERROR_TAG}>{tool_instance.get_name()} failed: {exc}" - - yield ToolResultEvent( - tool_name=tool_call.tool_name, - tool_class=tool_call.tool_class, - error=error_msg, - tool_call_id=tool_call.call_id, - ) - - if isinstance(exc, ToolPermissionError): - self.stats.tool_calls_agreed -= 1 - self.stats.tool_calls_rejected += 1 - else: - self.stats.tool_calls_failed += 1 - self._append_tool_response(tool_call, error_msg) - continue - - def _append_tool_response(self, tool_call: ResolvedToolCall, text: str) -> None: + def _handle_tool_response( + self, + tool_call: ResolvedToolCall, + text: str, + status: Literal["success", "failure", "skipped"], + decision: ToolDecision | None = None, + result: dict[str, Any] | None = None, + ) -> None: self.messages.append( LLMMessage.model_validate( self.format_handler.create_tool_response_message(tool_call, text) ) ) + self.telemetry_client.send_tool_call_finished( + tool_call=tool_call, + agent_profile_name=self.agent_profile.name, + status=status, + decision=decision, + result=result, + ) + async def _chat(self, max_tokens: int | None = None) -> LLMChunk: active_model = self.config.get_active_model() provider = self.config.get_provider_for_model(active_model) @@ -653,10 +702,7 @@ class AgentLoop: temperature=active_model.temperature, tools=available_tools, tool_choice=tool_choice, - extra_headers={ - "user-agent": get_user_agent(provider.backend), - "x-affinity": self.session_id, - }, + extra_headers=self._get_extra_headers(provider), max_tokens=max_tokens, ) end_time = time.perf_counter() @@ -699,10 +745,7 @@ class AgentLoop: temperature=active_model.temperature, tools=available_tools, tool_choice=tool_choice, - extra_headers={ - "user-agent": get_user_agent(provider.backend), - "x-affinity": self.session_id, - }, + extra_headers=self._get_extra_headers(provider), max_tokens=max_tokens, ): processed_message = self.format_handler.process_api_response_message( @@ -744,16 +787,23 @@ class AgentLoop: self, tool: BaseTool, args: BaseModel, tool_call_id: str ) -> ToolDecision: if self.auto_approve: - return ToolDecision(verdict=ToolExecutionResponse.EXECUTE) + return ToolDecision( + verdict=ToolExecutionResponse.EXECUTE, + approval_type=ToolPermission.ALWAYS, + ) allowlist_denylist_result = tool.check_allowlist_denylist(args) if allowlist_denylist_result == ToolPermission.ALWAYS: - return ToolDecision(verdict=ToolExecutionResponse.EXECUTE) + return ToolDecision( + verdict=ToolExecutionResponse.EXECUTE, + approval_type=ToolPermission.ALWAYS, + ) elif allowlist_denylist_result == ToolPermission.NEVER: denylist_patterns = tool.config.denylist denylist_str = ", ".join(repr(pattern) for pattern in denylist_patterns) return ToolDecision( verdict=ToolExecutionResponse.SKIP, + approval_type=ToolPermission.NEVER, feedback=f"Tool '{tool.get_name()}' blocked by denylist: [{denylist_str}]", ) @@ -761,10 +811,14 @@ class AgentLoop: perm = self.tool_manager.get_tool_config(tool_name).permission if perm is ToolPermission.ALWAYS: - return ToolDecision(verdict=ToolExecutionResponse.EXECUTE) + return ToolDecision( + verdict=ToolExecutionResponse.EXECUTE, + approval_type=ToolPermission.ALWAYS, + ) if perm is ToolPermission.NEVER: return ToolDecision( verdict=ToolExecutionResponse.SKIP, + approval_type=ToolPermission.NEVER, feedback=f"Tool '{tool_name}' is permanently disabled", ) @@ -776,6 +830,7 @@ class AgentLoop: if not self.approval_callback: return ToolDecision( verdict=ToolExecutionResponse.SKIP, + approval_type=ToolPermission.ASK, feedback="Tool execution not permitted.", ) if asyncio.iscoroutinefunction(self.approval_callback): @@ -788,11 +843,15 @@ class AgentLoop: match response: case ApprovalResponse.YES: return ToolDecision( - verdict=ToolExecutionResponse.EXECUTE, feedback=feedback + verdict=ToolExecutionResponse.EXECUTE, + approval_type=ToolPermission.ASK, + feedback=feedback, ) case ApprovalResponse.NO: return ToolDecision( - verdict=ToolExecutionResponse.SKIP, feedback=feedback + verdict=ToolExecutionResponse.SKIP, + approval_type=ToolPermission.ASK, + feedback=feedback, ) def _clean_message_history(self) -> None: @@ -890,7 +949,6 @@ class AgentLoop: self._reset_session() async def compact(self) -> str: - """Compact the conversation history.""" try: self._clean_message_history() await self.session_logger.save_interaction( @@ -915,6 +973,7 @@ class AgentLoop: system_message = self.messages[0] summary_message = LLMMessage(role=Role.user, content=summary_content) self.messages = [system_message, summary_message] + self._last_observed_message_index = 1 active_model = self.config.get_active_model() provider = self.config.get_provider_for_model(active_model) @@ -955,13 +1014,14 @@ class AgentLoop: if agent_name == self.agent_profile.name: return self.agent_manager.switch_profile(agent_name) - await self.reload_with_initial_messages() + await self.reload_with_initial_messages(reset_middleware=False) async def reload_with_initial_messages( self, base_config: VibeConfig | None = None, max_turns: int | None = None, max_price: float | None = None, + reset_middleware: bool = True, ) -> None: # Force an immediate yield to allow the UI to update before heavy sync work. # When there are no messages, save_interaction returns early without any await, @@ -1011,19 +1071,5 @@ class AgentLoop: except ValueError: pass - self._last_observed_message_index = 0 - - self._setup_middleware() - - if self.message_observer: - for msg in self.messages: - self.message_observer(msg) - self._last_observed_message_index = len(self.messages) - - await self.session_logger.save_interaction( - self.messages, - self.stats, - self._base_config, - self.tool_manager, - self.agent_profile, - ) + if reset_middleware: + self._setup_middleware() diff --git a/vibe/core/agents/models.py b/vibe/core/agents/models.py index a2cd8e2..608ab6a 100644 --- a/vibe/core/agents/models.py +++ b/vibe/core/agents/models.py @@ -110,7 +110,7 @@ EXPLORE = AgentProfile( description="Read-only subagent for codebase exploration", safety=AgentSafety.SAFE, agent_type=AgentType.SUBAGENT, - overrides={"enabled_tools": ["grep", "read_file"]}, + overrides={"enabled_tools": ["grep", "read_file"], "system_prompt_id": "explore"}, ) BUILTIN_AGENTS: dict[str, AgentProfile] = { diff --git a/vibe/core/config.py b/vibe/core/config.py index c8c615e..b04f03f 100644 --- a/vibe/core/config.py +++ b/vibe/core/config.py @@ -148,6 +148,8 @@ class ProviderConfig(BaseModel): api_style: str = "openai" backend: Backend = Backend.GENERIC reasoning_field_name: str = "reasoning_content" + project_id: str = "" + region: str = "" class _MCPBase(BaseModel): @@ -251,6 +253,7 @@ class ModelConfig(BaseModel): temperature: float = 0.2 input_price: float = 0.0 # Price per million input tokens output_price: float = 0.0 # Price per million output tokens + thinking: Literal["off", "low", "medium", "high"] = "off" @model_validator(mode="before") @classmethod @@ -312,6 +315,7 @@ class VibeConfig(BaseSettings): auto_compact_threshold: int = 200_000 context_warnings: bool = False auto_approve: bool = False + enable_telemetry: bool = True system_prompt_id: str = "cli" include_commit_signature: bool = True include_model_info: bool = True diff --git a/vibe/core/llm/backend/anthropic.py b/vibe/core/llm/backend/anthropic.py new file mode 100644 index 0000000..3533586 --- /dev/null +++ b/vibe/core/llm/backend/anthropic.py @@ -0,0 +1,630 @@ +from __future__ import annotations + +import json +import re +from typing import Any, ClassVar + +from vibe.core.config import ProviderConfig +from vibe.core.llm.backend.base import APIAdapter, PreparedRequest +from vibe.core.types import ( + AvailableTool, + FunctionCall, + LLMChunk, + LLMMessage, + LLMUsage, + Role, + StrToolChoice, + ToolCall, +) + + +class AnthropicMapper: + """Shared mapper for converting messages to/from Anthropic API format.""" + + def prepare_messages( + self, messages: list[LLMMessage] + ) -> tuple[str | None, list[dict[str, Any]]]: + system_prompt: str | None = None + converted: list[dict[str, Any]] = [] + + for msg in messages: + match msg.role: + case Role.system: + system_prompt = msg.content or "" + case Role.user: + user_content: list[dict[str, Any]] = [] + if msg.content: + user_content.append({"type": "text", "text": msg.content}) + converted.append({"role": "user", "content": user_content or ""}) + case Role.assistant: + converted.append(self._convert_assistant_message(msg)) + case Role.tool: + self._append_tool_result(converted, msg) + + return system_prompt, converted + + def _sanitize_tool_call_id(self, tool_id: str | None) -> str: + return re.sub(r"[^a-zA-Z0-9_-]", "_", tool_id or "") + + def _convert_assistant_message(self, msg: LLMMessage) -> dict[str, Any]: + content: list[dict[str, Any]] = [] + if msg.reasoning_content: + block: dict[str, Any] = { + "type": "thinking", + "thinking": msg.reasoning_content, + } + if msg.reasoning_signature: + block["signature"] = msg.reasoning_signature + content.append(block) + if msg.content: + content.append({"type": "text", "text": msg.content}) + if msg.tool_calls: + for tc in msg.tool_calls: + content.append(self._convert_tool_call(tc)) + return {"role": "assistant", "content": content if content else ""} + + def _convert_tool_call(self, tc: ToolCall) -> dict[str, Any]: + try: + tool_input = json.loads(tc.function.arguments or "{}") + except json.JSONDecodeError: + tool_input = {} + return { + "type": "tool_use", + "id": self._sanitize_tool_call_id(tc.id), + "name": tc.function.name, + "input": tool_input, + } + + def _append_tool_result( + self, converted: list[dict[str, Any]], msg: LLMMessage + ) -> None: + tool_result = { + "type": "tool_result", + "tool_use_id": self._sanitize_tool_call_id(msg.tool_call_id), + "content": msg.content or "", + } + + if not converted or converted[-1]["role"] != "user": + converted.append({"role": "user", "content": [tool_result]}) + return + + existing_content = converted[-1]["content"] + if isinstance(existing_content, str): + converted[-1]["content"] = [ + {"type": "text", "text": existing_content}, + tool_result, + ] + else: + converted[-1]["content"].append(tool_result) + + def prepare_tools( + self, tools: list[AvailableTool] | None + ) -> list[dict[str, Any]] | None: + if not tools: + return None + return [ + { + "name": tool.function.name, + "description": tool.function.description, + "input_schema": tool.function.parameters, + } + for tool in tools + ] + + def prepare_tool_choice( + self, tool_choice: StrToolChoice | AvailableTool | None + ) -> dict[str, Any] | None: + if tool_choice is None: + return None + if isinstance(tool_choice, str): + match tool_choice: + case "none": + return {"type": "none"} + case "auto": + return {"type": "auto"} + case "any" | "required": + return {"type": "any"} + case _: + return None + return {"type": "tool", "name": tool_choice.function.name} + + def parse_response(self, data: dict[str, Any]) -> LLMChunk: + content_blocks = data.get("content", []) + text_parts: list[str] = [] + thinking_parts: list[str] = [] + signature_parts: list[str] = [] + tool_calls: list[ToolCall] = [] + + for idx, block in enumerate(content_blocks): + block_type = block.get("type") + if block_type == "text": + text_parts.append(block.get("text", "")) + elif block_type == "thinking": + thinking_parts.append(block.get("thinking", "")) + if "signature" in block: + signature_parts.append(block["signature"]) + elif block_type == "tool_use": + tool_calls.append( + ToolCall( + id=block.get("id"), + index=idx, + function=FunctionCall( + name=block.get("name"), + arguments=json.dumps(block.get("input", {})), + ), + ) + ) + + usage_data = data.get("usage", {}) + # Total input tokens = input_tokens + cache_creation + cache_read + total_input_tokens = ( + usage_data.get("input_tokens", 0) + + usage_data.get("cache_creation_input_tokens", 0) + + usage_data.get("cache_read_input_tokens", 0) + ) + usage = LLMUsage( + prompt_tokens=total_input_tokens, + completion_tokens=usage_data.get("output_tokens", 0), + ) + + return LLMChunk( + message=LLMMessage( + role=Role.assistant, + content="".join(text_parts) or None, + reasoning_content="".join(thinking_parts) or None, + reasoning_signature="".join(signature_parts) or None, + tool_calls=tool_calls if tool_calls else None, + ), + usage=usage, + ) + + def parse_streaming_event( + self, event_type: str, data: dict[str, Any], current_index: int + ) -> tuple[LLMChunk | None, int]: + handler = { + "content_block_start": self._handle_block_start, + "content_block_delta": self._handle_block_delta, + "message_delta": self._handle_message_delta, + "message_start": self._handle_message_start, + }.get(event_type) + if handler is None: + return None, current_index + return handler(data, current_index) + + def _handle_block_start( + self, data: dict[str, Any], current_index: int + ) -> tuple[LLMChunk | None, int]: + block = data.get("content_block", {}) + idx = data.get("index", current_index) + + match block.get("type"): + case "tool_use": + chunk = LLMChunk( + message=LLMMessage( + role=Role.assistant, + tool_calls=[ + ToolCall( + id=block.get("id"), + index=idx, + function=FunctionCall( + name=block.get("name"), arguments="" + ), + ) + ], + ) + ) + return chunk, idx + case "thinking": + chunk = LLMChunk( + message=LLMMessage( + role=Role.assistant, reasoning_content=block.get("thinking", "") + ) + ) + return chunk, idx + case _: + return None, idx + + def _handle_block_delta( + self, data: dict[str, Any], current_index: int + ) -> tuple[LLMChunk | None, int]: + delta = data.get("delta", {}) + idx = data.get("index", current_index) + + match delta.get("type"): + case "text_delta": + chunk = LLMChunk( + message=LLMMessage( + role=Role.assistant, content=delta.get("text", "") + ) + ) + case "thinking_delta": + chunk = LLMChunk( + message=LLMMessage( + role=Role.assistant, reasoning_content=delta.get("thinking", "") + ) + ) + case "signature_delta": + chunk = LLMChunk( + message=LLMMessage( + role=Role.assistant, + reasoning_signature=delta.get("signature", ""), + ) + ) + case "input_json_delta": + chunk = LLMChunk( + message=LLMMessage( + role=Role.assistant, + tool_calls=[ + ToolCall( + index=idx, + function=FunctionCall( + arguments=delta.get("partial_json", "") + ), + ) + ], + ) + ) + case _: + chunk = None + return chunk, idx + + def _handle_message_delta( + self, data: dict[str, Any], current_index: int + ) -> tuple[LLMChunk | None, int]: + usage_data = data.get("usage", {}) + if not usage_data: + return None, current_index + chunk = LLMChunk( + message=LLMMessage(role=Role.assistant), + usage=LLMUsage( + prompt_tokens=0, completion_tokens=usage_data.get("output_tokens", 0) + ), + ) + return chunk, current_index + + def _handle_message_start( + self, data: dict[str, Any], current_index: int + ) -> tuple[LLMChunk | None, int]: + message = data.get("message", {}) + usage_data = message.get("usage", {}) + if not usage_data: + return None, current_index + # Total input tokens = input_tokens + cache_creation + cache_read + total_input_tokens = ( + usage_data.get("input_tokens", 0) + + usage_data.get("cache_creation_input_tokens", 0) + + usage_data.get("cache_read_input_tokens", 0) + ) + chunk = LLMChunk( + message=LLMMessage(role=Role.assistant), + usage=LLMUsage(prompt_tokens=total_input_tokens, completion_tokens=0), + ) + return chunk, current_index + + +STREAMING_EVENT_TYPES = { + "message_start", + "message_delta", + "message_stop", + "content_block_start", + "content_block_delta", + "content_block_stop", + "ping", + "error", +} + + +class AnthropicAdapter(APIAdapter): + endpoint: ClassVar[str] = "/v1/messages" + API_VERSION = "2023-06-01" + BETA_FEATURES = ( + "interleaved-thinking-2025-05-14," + "fine-grained-tool-streaming-2025-05-14," + "prompt-caching-2024-07-31" + ) + THINKING_BUDGETS: ClassVar[dict[str, int]] = { + "low": 1024, + "medium": 10_000, + "high": 32_000, + } + DEFAULT_ADAPTIVE_MAX_TOKENS: ClassVar[int] = 32_768 + DEFAULT_MAX_TOKENS = 8192 + + def __init__(self) -> None: + self._mapper = AnthropicMapper() + self._current_index: int = 0 + + @staticmethod + def _has_thinking_content(messages: list[dict[str, Any]]) -> bool: + for msg in messages: + if msg.get("role") != "assistant": + continue + content = msg.get("content") + if not isinstance(content, list): + continue + for block in content: + if block.get("type") == "thinking": + return True + return False + + @staticmethod + def _build_system_blocks(system_prompt: str | None) -> list[dict[str, Any]]: + blocks: list[dict[str, Any]] = [] + if system_prompt: + blocks.append({ + "type": "text", + "text": system_prompt, + "cache_control": {"type": "ephemeral"}, + }) + return blocks + + @staticmethod + def _add_cache_control_to_last_user_message(messages: list[dict[str, Any]]) -> None: + if not messages: + return + last_message = messages[-1] + if last_message.get("role") != "user": + return + content = last_message.get("content") + if not isinstance(content, list) or not content: + return + last_block = content[-1] + if last_block.get("type") in {"text", "image", "tool_result"}: + last_block["cache_control"] = {"type": "ephemeral"} + + @staticmethod + def _is_adaptive_model(model_name: str) -> bool: + return "opus-4-6" in model_name + + def _apply_thinking_config( + self, + payload: dict[str, Any], + *, + model_name: str, + messages: list[dict[str, Any]], + temperature: float, + max_tokens: int | None, + thinking: str, + ) -> None: + has_thinking = self._has_thinking_content(messages) + thinking_level = thinking + + if thinking_level == "off" and not has_thinking: + payload["temperature"] = temperature + if max_tokens is not None: + payload["max_tokens"] = max_tokens + else: + payload["max_tokens"] = self.DEFAULT_MAX_TOKENS + return + + # Resolve effective level: use config, or fallback to "medium" when + # forced by thinking content in history + effective_level = thinking_level if thinking_level != "off" else "medium" + + if self._is_adaptive_model(model_name): + payload["thinking"] = {"type": "adaptive"} + payload["output_config"] = {"effort": effective_level} + default_max = self.DEFAULT_ADAPTIVE_MAX_TOKENS + else: + budget = self.THINKING_BUDGETS[effective_level] + payload["thinking"] = {"type": "enabled", "budget_tokens": budget} + default_max = budget + self.DEFAULT_MAX_TOKENS + + payload["temperature"] = 1 + payload["max_tokens"] = max_tokens if max_tokens is not None else default_max + + def _build_payload( + self, + *, + model_name: str, + system_prompt: str | None, + messages: list[dict[str, Any]], + temperature: float, + tools: list[dict[str, Any]] | None, + max_tokens: int | None, + tool_choice: dict[str, Any] | None, + stream: bool, + thinking: str, + ) -> dict[str, Any]: + payload: dict[str, Any] = {"model": model_name, "messages": messages} + + self._apply_thinking_config( + payload, + model_name=model_name, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + thinking=thinking, + ) + + if system_blocks := self._build_system_blocks(system_prompt): + payload["system"] = system_blocks + + if tools: + payload["tools"] = tools + + if tool_choice: + payload["tool_choice"] = tool_choice + + if stream: + payload["stream"] = True + + self._add_cache_control_to_last_user_message(messages) + + return payload + + def prepare_request( # noqa: PLR0913 + self, + *, + model_name: str, + messages: list[LLMMessage], + temperature: float, + tools: list[AvailableTool] | None, + max_tokens: int | None, + tool_choice: StrToolChoice | AvailableTool | None, + enable_streaming: bool, + provider: ProviderConfig, + api_key: str | None = None, + thinking: str = "off", + ) -> PreparedRequest: + system_prompt, converted_messages = self._mapper.prepare_messages(messages) + converted_tools = self._mapper.prepare_tools(tools) + converted_tool_choice = self._mapper.prepare_tool_choice(tool_choice) + + payload = self._build_payload( + model_name=model_name, + system_prompt=system_prompt, + messages=converted_messages, + temperature=temperature, + tools=converted_tools, + max_tokens=max_tokens, + tool_choice=converted_tool_choice, + stream=enable_streaming, + thinking=thinking, + ) + + headers = { + "Content-Type": "application/json", + "anthropic-version": self.API_VERSION, + "anthropic-beta": self.BETA_FEATURES, + } + + if api_key: + headers["x-api-key"] = api_key + + body = json.dumps(payload).encode("utf-8") + return PreparedRequest(self.endpoint, headers, body) + + def parse_response( + self, data: dict[str, Any], provider: ProviderConfig | None = None + ) -> LLMChunk: + event_type = data.get("type") + if event_type in STREAMING_EVENT_TYPES: + return self._parse_streaming_event(data) + return self._mapper.parse_response(data) + + def _parse_streaming_event(self, data: dict[str, Any]) -> LLMChunk: + event_type = data.get("type", "") + empty_chunk = LLMChunk(message=LLMMessage(role=Role.assistant, content=None)) + + if event_type == "message_start": + self._current_index = 0 + return self._parse_message_start(data) + if event_type == "content_block_start": + return self._parse_content_block_start(data) or empty_chunk + if event_type == "content_block_delta": + return self._parse_content_block_delta(data) + if event_type == "content_block_stop": + return self._parse_content_block_stop(data) + if event_type == "message_delta": + return self._parse_message_delta(data) + if event_type == "error": + error = data.get("error", {}) + error_type = error.get("type", "unknown_error") + error_message = error.get("message", "Unknown streaming error") + raise RuntimeError( + f"Anthropic stream error ({error_type}): {error_message}" + ) + return empty_chunk + + def _parse_message_start(self, data: dict[str, Any]) -> LLMChunk: + message = data.get("message", {}) + usage_data = message.get("usage", {}) + if not usage_data: + return LLMChunk(message=LLMMessage(role=Role.assistant, content=None)) + total_input_tokens = ( + usage_data.get("input_tokens", 0) + + usage_data.get("cache_creation_input_tokens", 0) + + usage_data.get("cache_read_input_tokens", 0) + ) + return LLMChunk( + message=LLMMessage(role=Role.assistant, content=None), + usage=LLMUsage(prompt_tokens=total_input_tokens, completion_tokens=0), + ) + + def _parse_content_block_start(self, data: dict[str, Any]) -> LLMChunk | None: + content_block = data.get("content_block", {}) + index = data.get("index", 0) + block_type = content_block.get("type") + + if block_type == "thinking": + return LLMChunk( + message=LLMMessage( + role=Role.assistant, + reasoning_content=content_block.get("thinking", ""), + ) + ) + if block_type == "redacted_thinking": + return None + if block_type == "tool_use": + return LLMChunk( + message=LLMMessage( + role=Role.assistant, + tool_calls=[ + ToolCall( + index=index, + id=content_block.get("id"), + function=FunctionCall( + name=content_block.get("name"), arguments="" + ), + ) + ], + ) + ) + return None + + def _parse_content_block_delta(self, data: dict[str, Any]) -> LLMChunk: + delta = data.get("delta", {}) + delta_type = delta.get("type", "") + index = data.get("index", 0) + + match delta_type: + case "text_delta": + return LLMChunk( + message=LLMMessage( + role=Role.assistant, content=delta.get("text", "") + ) + ) + case "thinking_delta": + return LLMChunk( + message=LLMMessage( + role=Role.assistant, reasoning_content=delta.get("thinking", "") + ) + ) + case "signature_delta": + return LLMChunk( + message=LLMMessage( + role=Role.assistant, + reasoning_signature=delta.get("signature", ""), + ) + ) + case "input_json_delta": + return LLMChunk( + message=LLMMessage( + role=Role.assistant, + tool_calls=[ + ToolCall( + index=index, + function=FunctionCall( + arguments=delta.get("partial_json", "") + ), + ) + ], + ) + ) + case _: + return LLMChunk(message=LLMMessage(role=Role.assistant, content=None)) + + def _parse_content_block_stop(self, _data: dict[str, Any]) -> LLMChunk: + return LLMChunk(message=LLMMessage(role=Role.assistant, content=None)) + + def _parse_message_delta(self, data: dict[str, Any]) -> LLMChunk: + usage_data = data.get("usage", {}) + if not usage_data: + return LLMChunk(message=LLMMessage(role=Role.assistant, content=None)) + return LLMChunk( + message=LLMMessage(role=Role.assistant, content=None), + usage=LLMUsage( + prompt_tokens=0, completion_tokens=usage_data.get("output_tokens", 0) + ), + ) diff --git a/vibe/core/llm/backend/base.py b/vibe/core/llm/backend/base.py new file mode 100644 index 0000000..6fa101d --- /dev/null +++ b/vibe/core/llm/backend/base.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol + +from vibe.core.types import AvailableTool, LLMChunk, LLMMessage, StrToolChoice + +if TYPE_CHECKING: + from vibe.core.config import ProviderConfig + + +class PreparedRequest(NamedTuple): + endpoint: str + headers: dict[str, str] + body: bytes + base_url: str = "" + + +class APIAdapter(Protocol): + endpoint: ClassVar[str] + + def prepare_request( # noqa: PLR0913 + self, + *, + model_name: str, + messages: list[LLMMessage], + temperature: float, + tools: list[AvailableTool] | None, + max_tokens: int | None, + tool_choice: StrToolChoice | AvailableTool | None, + enable_streaming: bool, + provider: ProviderConfig, + api_key: str | None = None, + thinking: str = "off", + ) -> PreparedRequest: ... + + def parse_response( + self, data: dict[str, Any], provider: ProviderConfig + ) -> LLMChunk: ... diff --git a/vibe/core/llm/backend/generic.py b/vibe/core/llm/backend/generic.py index 2a572ec..c76214f 100644 --- a/vibe/core/llm/backend/generic.py +++ b/vibe/core/llm/backend/generic.py @@ -1,14 +1,18 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator import json import os import types -from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, Protocol, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple import httpx +from vibe.core.llm.backend.anthropic import AnthropicAdapter +from vibe.core.llm.backend.base import APIAdapter, PreparedRequest +from vibe.core.llm.backend.vertex import VertexAnthropicAdapter from vibe.core.llm.exceptions import BackendErrorBuilder +from vibe.core.llm.message_utils import merge_consecutive_user_messages from vibe.core.types import ( AvailableTool, LLMChunk, @@ -23,51 +27,6 @@ if TYPE_CHECKING: from vibe.core.config import ModelConfig, ProviderConfig -class PreparedRequest(NamedTuple): - endpoint: str - headers: dict[str, str] - body: bytes - - -class APIAdapter(Protocol): - endpoint: ClassVar[str] - - def prepare_request( - self, - *, - model_name: str, - messages: list[LLMMessage], - temperature: float, - tools: list[AvailableTool] | None, - max_tokens: int | None, - tool_choice: StrToolChoice | AvailableTool | None, - enable_streaming: bool, - provider: ProviderConfig, - api_key: str | None = None, - ) -> PreparedRequest: ... - - def parse_response( - self, data: dict[str, Any], provider: ProviderConfig - ) -> LLMChunk: ... - - -BACKEND_ADAPTERS: dict[str, APIAdapter] = {} - -T = TypeVar("T", bound=APIAdapter) - - -def register_adapter( - adapters: dict[str, APIAdapter], name: str -) -> Callable[[type[T]], type[T]]: - - def decorator(cls: type[T]) -> type[T]: - adapters[name] = cls() - return cls - - return decorator - - -@register_adapter(BACKEND_ADAPTERS, "openai") class OpenAIAdapter(APIAdapter): endpoint: ClassVar[str] = "/chat/completions" @@ -119,7 +78,7 @@ class OpenAIAdapter(APIAdapter): msg_dict["reasoning_content"] = msg_dict.pop(field_name) return msg_dict - def prepare_request( + def prepare_request( # noqa: PLR0913 self, *, model_name: str, @@ -131,13 +90,15 @@ class OpenAIAdapter(APIAdapter): enable_streaming: bool, provider: ProviderConfig, api_key: str | None = None, + thinking: str = "off", ) -> PreparedRequest: + merged_messages = merge_consecutive_user_messages(messages) field_name = provider.reasoning_field_name converted_messages = [ self._reasoning_to_api( msg.model_dump(exclude_none=True, exclude={"message_id"}), field_name ) - for msg in messages + for msg in merged_messages ] payload = self.build_payload( @@ -194,6 +155,13 @@ class OpenAIAdapter(APIAdapter): return LLMChunk(message=message, usage=usage) +ADAPTERS: dict[str, APIAdapter] = { + "openai": OpenAIAdapter(), + "anthropic": AnthropicAdapter(), + "vertex-anthropic": VertexAnthropicAdapter(), +} + + class GenericBackend: def __init__( self, @@ -257,9 +225,9 @@ class GenericBackend: ) api_style = getattr(self._provider, "api_style", "openai") - adapter = BACKEND_ADAPTERS[api_style] + adapter = ADAPTERS[api_style] - endpoint, headers, body = adapter.prepare_request( + req = adapter.prepare_request( model_name=model.name, messages=messages, temperature=temperature, @@ -269,15 +237,18 @@ class GenericBackend: enable_streaming=False, provider=self._provider, api_key=api_key, + thinking=model.thinking, ) + headers = req.headers if extra_headers: headers.update(extra_headers) - url = f"{self._provider.api_base}{endpoint}" + base = req.base_url or self._provider.api_base + url = f"{base}{req.endpoint}" try: - res_data, _ = await self._make_request(url, body, headers) + res_data, _ = await self._make_request(url, req.body, headers) return adapter.parse_response(res_data, self._provider) except httpx.HTTPStatusError as e: @@ -322,9 +293,9 @@ class GenericBackend: ) api_style = getattr(self._provider, "api_style", "openai") - adapter = BACKEND_ADAPTERS[api_style] + adapter = ADAPTERS[api_style] - endpoint, headers, body = adapter.prepare_request( + req = adapter.prepare_request( model_name=model.name, messages=messages, temperature=temperature, @@ -334,15 +305,18 @@ class GenericBackend: enable_streaming=True, provider=self._provider, api_key=api_key, + thinking=model.thinking, ) + headers = req.headers if extra_headers: headers.update(extra_headers) - url = f"{self._provider.api_base}{endpoint}" + base = req.base_url or self._provider.api_base + url = f"{base}{req.endpoint}" try: - async for res_data in self._make_streaming_request(url, body, headers): + async for res_data in self._make_streaming_request(url, req.body, headers): yield adapter.parse_response(res_data, self._provider) except httpx.HTTPStatusError as e: @@ -393,6 +367,8 @@ class GenericBackend: async with client.stream( method="POST", url=url, content=data, headers=headers ) as response: + if not response.is_success: + await response.aread() response.raise_for_status() async for line in response.aiter_lines(): if line.strip() == "": diff --git a/vibe/core/llm/backend/mistral.py b/vibe/core/llm/backend/mistral.py index 1333206..d3bee2f 100644 --- a/vibe/core/llm/backend/mistral.py +++ b/vibe/core/llm/backend/mistral.py @@ -11,6 +11,7 @@ import httpx import mistralai from vibe.core.llm.exceptions import BackendErrorBuilder +from vibe.core.llm.message_utils import merge_consecutive_user_messages from vibe.core.types import ( AvailableTool, Content, @@ -217,9 +218,10 @@ class MistralBackend: extra_headers: dict[str, str] | None, ) -> LLMChunk: try: + merged_messages = merge_consecutive_user_messages(messages) response = await self._get_client().chat.complete_async( model=model.name, - messages=[self._mapper.prepare_message(msg) for msg in messages], + messages=[self._mapper.prepare_message(msg) for msg in merged_messages], temperature=temperature, tools=[self._mapper.prepare_tool(tool) for tool in tools] if tools @@ -290,9 +292,10 @@ class MistralBackend: extra_headers: dict[str, str] | None, ) -> AsyncGenerator[LLMChunk, None]: try: + merged_messages = merge_consecutive_user_messages(messages) async for chunk in await self._get_client().chat.stream_async( model=model.name, - messages=[self._mapper.prepare_message(msg) for msg in messages], + messages=[self._mapper.prepare_message(msg) for msg in merged_messages], temperature=temperature, tools=[self._mapper.prepare_tool(tool) for tool in tools] if tools diff --git a/vibe/core/llm/backend/vertex.py b/vibe/core/llm/backend/vertex.py new file mode 100644 index 0000000..7b14834 --- /dev/null +++ b/vibe/core/llm/backend/vertex.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import json +from typing import Any, ClassVar + +import google.auth +from google.auth.transport.requests import Request + +from vibe.core.config import ProviderConfig +from vibe.core.llm.backend.anthropic import AnthropicAdapter +from vibe.core.llm.backend.base import PreparedRequest +from vibe.core.types import AvailableTool, LLMMessage, StrToolChoice + + +def get_vertex_access_token() -> str: + + credentials, _ = google.auth.default( + scopes=["https://www.googleapis.com/auth/cloud-platform"] + ) + credentials.refresh(Request()) + return credentials.token + + +def build_vertex_base_url(region: str) -> str: + if region == "global": + return "https://aiplatform.googleapis.com" + return f"https://{region}-aiplatform.googleapis.com" + + +def build_vertex_endpoint( + region: str, project_id: str, model: str, streaming: bool = False +) -> str: + action = "streamRawPredict" if streaming else "rawPredict" + return ( + f"/v1/projects/{project_id}/locations/{region}/" + f"publishers/anthropic/models/{model}:{action}" + ) + + +class VertexAnthropicAdapter(AnthropicAdapter): + """Vertex AI adapter — inherits all streaming/parsing from AnthropicAdapter.""" + + endpoint: ClassVar[str] = "" + # Vertex AI doesn't support beta features + BETA_FEATURES: ClassVar[str] = "" + + def prepare_request( # noqa: PLR0913 + self, + *, + model_name: str, + messages: list[LLMMessage], + temperature: float, + tools: list[AvailableTool] | None, + max_tokens: int | None, + tool_choice: StrToolChoice | AvailableTool | None, + enable_streaming: bool, + provider: ProviderConfig, + api_key: str | None = None, + thinking: str = "off", + ) -> PreparedRequest: + project_id = provider.project_id + region = provider.region + + if not project_id: + raise ValueError("project_id is required in provider config for Vertex AI") + if not region: + raise ValueError("region is required in provider config for Vertex AI") + + system_prompt, converted_messages = self._mapper.prepare_messages(messages) + converted_tools = self._mapper.prepare_tools(tools) + converted_tool_choice = self._mapper.prepare_tool_choice(tool_choice) + + # Build vertex-specific payload (no "model" key, uses anthropic_version) + payload: dict[str, Any] = { + "anthropic_version": "vertex-2023-10-16", + "messages": converted_messages, + } + self._apply_thinking_config( + payload, + model_name=model_name, + messages=converted_messages, + temperature=temperature, + max_tokens=max_tokens, + thinking=thinking, + ) + + if system_blocks := self._build_system_blocks(system_prompt): + payload["system"] = system_blocks + + if converted_tools: + payload["tools"] = converted_tools + + if converted_tool_choice: + payload["tool_choice"] = converted_tool_choice + + if enable_streaming: + payload["stream"] = True + + self._add_cache_control_to_last_user_message(converted_messages) + + access_token = get_vertex_access_token() + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {access_token}", + "anthropic-beta": self.BETA_FEATURES, + } + + endpoint = build_vertex_endpoint( + region, project_id, model_name, streaming=enable_streaming + ) + base_url = build_vertex_base_url(region) + + body = json.dumps(payload).encode("utf-8") + return PreparedRequest(endpoint, headers, body, base_url=base_url) diff --git a/vibe/core/llm/format.py b/vibe/core/llm/format.py index ac852d4..773bd0b 100644 --- a/vibe/core/llm/format.py +++ b/vibe/core/llm/format.py @@ -80,6 +80,7 @@ class APIToolFormatHandler: "role": message.role, "content": message.content, "reasoning_content": getattr(message, "reasoning_content", None), + "reasoning_signature": getattr(message, "reasoning_signature", None), } if message.tool_calls: diff --git a/vibe/core/llm/message_utils.py b/vibe/core/llm/message_utils.py new file mode 100644 index 0000000..db97e66 --- /dev/null +++ b/vibe/core/llm/message_utils.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from vibe.core.types import LLMMessage, Role + + +def merge_consecutive_user_messages(messages: list[LLMMessage]) -> list[LLMMessage]: + """Merge consecutive user messages into a single message. + + This handles cases where middleware injects messages resulting in + consecutive user messages before sending to the API. + """ + result: list[LLMMessage] = [] + for msg in messages: + if result and result[-1].role == Role.user and msg.role == Role.user: + prev_content = result[-1].content or "" + curr_content = msg.content or "" + merged_content = f"{prev_content}\n\n{curr_content}".strip() + result[-1] = LLMMessage( + role=Role.user, content=merged_content, message_id=result[-1].message_id + ) + else: + result.append(msg) + + return result diff --git a/vibe/core/middleware.py b/vibe/core/middleware.py index 36a97e2..9b70b11 100644 --- a/vibe/core/middleware.py +++ b/vibe/core/middleware.py @@ -44,8 +44,6 @@ class MiddlewareResult: class ConversationMiddleware(Protocol): async def before_turn(self, context: ConversationContext) -> MiddlewareResult: ... - async def after_turn(self, context: ConversationContext) -> MiddlewareResult: ... - def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None: ... @@ -61,9 +59,6 @@ class TurnLimitMiddleware: ) return MiddlewareResult() - async def after_turn(self, context: ConversationContext) -> MiddlewareResult: - return MiddlewareResult() - def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None: pass @@ -80,9 +75,6 @@ class PriceLimitMiddleware: ) return MiddlewareResult() - async def after_turn(self, context: ConversationContext) -> MiddlewareResult: - return MiddlewareResult() - def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None: pass @@ -102,9 +94,6 @@ class AutoCompactMiddleware: ) return MiddlewareResult() - async def after_turn(self, context: ConversationContext) -> MiddlewareResult: - return MiddlewareResult() - def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None: pass @@ -137,9 +126,6 @@ class ContextWarningMiddleware: return MiddlewareResult() - async def after_turn(self, context: ConversationContext) -> MiddlewareResult: - return MiddlewareResult() - def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None: self.has_warned = False @@ -148,31 +134,46 @@ PLAN_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Plan mode is active. The user indi 1. Answer the user's query comprehensively 2. When you're done researching, present your plan by giving the full plan and not doing further tool calls to return input to the user. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan.""" +PLAN_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Plan mode has ended. If you have a plan ready, you can now start executing it. If not, you can now use editing tools and make changes to the system.""" + class PlanAgentMiddleware: def __init__( self, profile_getter: Callable[[], AgentProfile], reminder: str = PLAN_AGENT_REMINDER, + exit_message: str = PLAN_AGENT_EXIT, ) -> None: self._profile_getter = profile_getter self.reminder = reminder + self.exit_message = exit_message + self._was_plan_agent = False def _is_plan_agent(self) -> bool: return self._profile_getter().name == BuiltinAgentName.PLAN async def before_turn(self, context: ConversationContext) -> MiddlewareResult: - if not self._is_plan_agent(): - return MiddlewareResult() - return MiddlewareResult( - action=MiddlewareAction.INJECT_MESSAGE, message=self.reminder - ) + is_plan = self._is_plan_agent() + was_plan = self._was_plan_agent + + if was_plan and not is_plan: + self._was_plan_agent = False + return MiddlewareResult( + action=MiddlewareAction.INJECT_MESSAGE, message=self.exit_message + ) + + if is_plan and not was_plan: + self._was_plan_agent = True + return MiddlewareResult( + action=MiddlewareAction.INJECT_MESSAGE, message=self.reminder + ) + + self._was_plan_agent = is_plan - async def after_turn(self, context: ConversationContext) -> MiddlewareResult: return MiddlewareResult() def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None: - pass + self._was_plan_agent = False class MiddlewarePipeline: @@ -206,15 +207,3 @@ class MiddlewarePipeline: ) return MiddlewareResult() - - async def run_after_turn(self, context: ConversationContext) -> MiddlewareResult: - for mw in self.middlewares: - result = await mw.after_turn(context) - if result.action == MiddlewareAction.INJECT_MESSAGE: - raise ValueError( - f"INJECT_MESSAGE not allowed in after_turn (from {type(mw).__name__})" - ) - if result.action in {MiddlewareAction.STOP, MiddlewareAction.COMPACT}: - return result - - return MiddlewareResult() diff --git a/vibe/core/paths/config_paths.py b/vibe/core/paths/config_paths.py index 368554e..58188fd 100644 --- a/vibe/core/paths/config_paths.py +++ b/vibe/core/paths/config_paths.py @@ -39,12 +39,14 @@ def resolve_local_tools_dir(dir: Path) -> Path | None: return None -def resolve_local_skills_dir(dir: Path) -> Path | None: +def resolve_local_skills_dirs(dir: Path) -> list[Path]: if not trusted_folders_manager.is_trusted(dir): - return None - if (candidate := dir / ".vibe" / "skills").is_dir(): - return candidate - return None + return [] + return [ + candidate + for candidate in [dir / ".vibe" / "skills", dir / ".agents" / "skills"] + if candidate.is_dir() + ] def resolve_local_agents_dir(dir: Path) -> Path | None: diff --git a/vibe/core/paths/global_paths.py b/vibe/core/paths/global_paths.py index e305552..9489f01 100644 --- a/vibe/core/paths/global_paths.py +++ b/vibe/core/paths/global_paths.py @@ -35,6 +35,6 @@ GLOBAL_PROMPTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "prompts") SESSION_LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs" / "session") TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml") LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs") -LOG_FILE = GlobalPath(lambda: VIBE_HOME.path / "vibe.log") +LOG_FILE = GlobalPath(lambda: VIBE_HOME.path / "logs" / "vibe.log") DEFAULT_TOOL_DIR = GlobalPath(lambda: VIBE_ROOT / "core" / "tools" / "builtins") diff --git a/vibe/core/programmatic.py b/vibe/core/programmatic.py index f23552e..b00c4be 100644 --- a/vibe/core/programmatic.py +++ b/vibe/core/programmatic.py @@ -32,20 +32,25 @@ def run_programmatic( logger.info("USER: %s", prompt) async def _async_run() -> str | None: - if previous_messages: - non_system_messages = [ - msg for msg in previous_messages if not (msg.role == Role.system) - ] - agent_loop.messages.extend(non_system_messages) - logger.info( - "Loaded %d messages from previous session", len(non_system_messages) - ) + try: + if previous_messages: + non_system_messages = [ + msg for msg in previous_messages if not (msg.role == Role.system) + ] + agent_loop.messages.extend(non_system_messages) + logger.info( + "Loaded %d messages from previous session", len(non_system_messages) + ) - async for event in agent_loop.act(prompt): - formatter.on_event(event) - if isinstance(event, AssistantEvent) and event.stopped_by_middleware: - raise ConversationLimitException(event.content) + agent_loop.emit_new_session_telemetry("programmatic") - return formatter.finalize() + async for event in agent_loop.act(prompt): + formatter.on_event(event) + if isinstance(event, AssistantEvent) and event.stopped_by_middleware: + raise ConversationLimitException(event.content) + + return formatter.finalize() + finally: + await agent_loop.telemetry_client.aclose() return asyncio.run(_async_run()) diff --git a/vibe/core/prompts/__init__.py b/vibe/core/prompts/__init__.py index a435a6d..f86bd7c 100644 --- a/vibe/core/prompts/__init__.py +++ b/vibe/core/prompts/__init__.py @@ -19,6 +19,7 @@ class Prompt(StrEnum): class SystemPrompt(Prompt): CLI = auto() + EXPLORE = auto() TESTS = auto() diff --git a/vibe/core/prompts/cli.md b/vibe/core/prompts/cli.md index 38f5778..d21527b 100644 --- a/vibe/core/prompts/cli.md +++ b/vibe/core/prompts/cli.md @@ -1,46 +1,111 @@ -You are operating as and within Mistral Vibe, a CLI coding-agent built by Mistral AI and powered by default by the Devstral family of models. It wraps Mistral's Devstral models to enable natural language interaction with a local codebase. Use the available tools when helpful. +You are Mistral Vibe, a CLI coding agent built by Mistral AI, powered by the Devstral model family. You interact with a local codebase through tools. -Act as an agentic assistant. For long tasks, break them down and execute step by step. +Phase 1 — Orient +Before ANY action: +Restate the goal in one line. +Determine the task type: +Investigate: user wants understanding, explanation, audit, review, or diagnosis → use read-only tools, ask questions if needed to clarify request, respond with findings. Do not edit files. +Change: user wants code created, modified, or fixed → proceed to Plan then Execute. +If unclear, default to investigate. It is better to explain what you would do than to make an unwanted change. -## Tool Usage +Explore. Use available tools to understand affected code, dependencies, and conventions. Never edit a file you haven't read in this session. +Identify constraints: language, framework, test setup, and any user restrictions on scope. -- Always use tools to fulfill user requests when possible. -- Check that all required parameters are provided or can be inferred from context. If values are missing, ask the user. -- When the user provides a specific value (e.g., in quotes), use it EXACTLY as given. -- Do not invent values for optional parameters. -- Analyze descriptive terms in requests as they may indicate required parameter values. -- If tools cannot accomplish the task, explain why and request more information. +Phase 2 — Plan (Change tasks only) +State your plan before writing code: +List files to change and the specific change per file. +Multi-file changes: numbered checklist. Single-file fix: one-line plan. +No time estimates. Concrete actions only. -## Code Modifications +Phase 3 — Execute & Verify (Change tasks only) +Apply changes, then confirm they work: +Edit one logical unit at a time. +After each unit, verify: run tests, or read back the file to confirm the edit landed. +Never claim completion without verification — a passing test, correct read-back, or successful build. -- Always read a file before proposing changes. Never suggest edits to code you haven't seen. -- Keep changes minimal and focused. Only modify what was requested. -- Avoid over-engineering: no extra features, unnecessary abstractions, or speculative error handling. -- NEVER add backward-compatibility hacks. No `_unused` variable renames, no re-exporting dead code, no `// removed` comments, no shims or wrappers to preserve old interfaces. If code is unused, delete it completely. If an interface changes, update all call sites. Clean rewrites are always preferred over compatibility layers. -- Be mindful of common security pitfalls (injection, XSS, SQLI, etc.). Fix insecure code immediately if you spot it. -- Match the existing style of the file. Avoid adding comments, defensive checks, try/catch blocks, or type casts that are inconsistent with surrounding code. Write like a human contributor to that codebase would. +Hard Rules +Respect User Constraints +"No writes", "just analyze", "plan only", "don't touch X" — these are hard constraints. Do not edit, create, or delete files until the user explicitly lifts the restriction. Violation of explicit user instructions is the worst failure mode. -## Code References +Don't Remove What Wasn't Asked +If user asks to fix X, do not rewrite, delete, or restructure Y. When in doubt, change less. -When mentioning specific code locations, use the format `file_path:line_number` so users can navigate directly. +Don't Assert — Verify +If unsure about a file path, variable value, config state, or whether your edit worked — use a tool to check. Read the file. Run the command. -## Planning +Break Loops +If approach isn't working after 2 attempts at the same region, STOP: +Re-read the code and error output. +Identify why it failed, not just what failed. +Choose a fundamentally different strategy. +If stuck, ask the user one specific question. -When outlining steps or plans, focus on concrete actions. Do not include time estimates. +Flip-flopping (add X → remove X → add X) is a critical failure. Commit to a direction or escalate. -## Tone and Style +Response Format +No Noise +No greetings, outros, hedging, puffery, or tool narration. -- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. -- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. -- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. -- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files. -- Never create markdown files, READMEs, or changelogs unless the user explicitly requests documentation. +Never say: "Certainly", "Of course", "Let me help", "Happy to", "I hope this helps", "Let me search…", "I'll now read…", "Great question!", "In summary…" +Never use: "robust", "seamless", "elegant", "powerful", "flexible" +No unsolicited tutorials. Do not explain concepts the user clearly knows. -## Professional Objectivity +Structure First +Lead every response with the most useful structured element — code, diagram, table, or tree. Prose comes after, not before. +For change tasks: +file_path:line_number +langcode -- Prioritize technical accuracy and truthfulness over validating the user's beliefs. -- Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. -- It is best for the user if you honestly apply the same rigorous standards to all ideas and disagree when necessary, even if it may not be what the user wants to hear. -- Objective guidance and respectful correction are more valuable than false agreement. -- Whenever there is uncertainty, investigate to find the truth first rather than instinctively confirming the user's beliefs. -- Avoid using over-the-top validation or excessive praise when responding to users such as "You're absolutely right" or similar phrases. +Prefer Brevity +State only what's necessary to complete the task. Code + file reference > explanation. +If your response exceeds 300 words, remove explanations the user didn't request. + +For investigate tasks: +Start with a diagram, code reference, tree, or table — whichever conveys the answer fastest. +request → auth.verify() → permissions.check() → handler +See middleware/auth.py:45. Then 1-2 sentences of context if needed. +BAD: "The authentication flow works by first checking the token…" +GOOD: request → auth.verify() → permissions.check() → handler — see middleware/auth.py:45. +Visual Formats + +Before responding with structural data, choose the right format: +BAD: Bullet lists for hierarchy/tree +GOOD: ASCII tree (├──/└──) +BAD: Prose or bullet lists for comparisons/config/options +GOOD: Markdown table +BAD: Prose for Flows/pipelines +GOOD: → A → B → C diagrams + +Interaction Design +After completing a task, evaluate: does the user face a decision or tradeoff? If yes, end with ONE specific question or 2-3 options: + +Good: "Apply this fix to the other 3 endpoints?" +Good: "Two approaches: (a) migration, (b) recreate table. Which?" +Bad: "Does this look good?", "Anything else?", "Let me know" + +If unambiguous and complete, end with the result. + +Length +Default to minimal responses. One-line fix → one-line response. Most tasks need <200 words. +Elaborate only when: (1) user asks for explanation, (2) task involves architectural decisions, (3) multiple valid approaches exist. + +Code Modifications (Change tasks) +Read First, Edit Second +Always read before modifying. Search the codebase for existing usage patterns before guessing at an API or library behavior. + +Minimal, Focused Changes +Only modify what was requested. No extra features, abstractions, or speculative error handling. +Match existing style: indentation, naming, comment density, error handling. +When removing code, delete completely. No _unused renames, // removed comments, shims, or wrappers. If an interface changes, update all call sites. + +Security +Fix injection, XSS, SQLi vulnerabilities immediately if spotted. + +Code References +Cite as file_path:line_number. + +Professional Conduct +Prioritize technical accuracy over validating beliefs. Disagree when necessary. +When uncertain, investigate before confirming. +No emojis unless requested. No over-the-top validation. +Stay focused on solving the problem regardless of user tone. Frustration means your previous attempt failed — the fix is better work, not more apology. diff --git a/vibe/core/prompts/explore.md b/vibe/core/prompts/explore.md new file mode 100644 index 0000000..1c60f77 --- /dev/null +++ b/vibe/core/prompts/explore.md @@ -0,0 +1,50 @@ +You are a senior engineer analyzing codebases. Be direct and useful. + +Response Format + +1. **CODE/DIAGRAM FIRST** — Start with code, diagram, or structured output. Never prose first. +2. **MINIMAL CONTEXT** — After code: 1-2 sentences max. Code should be self-explanatory. + +Never Do + +- Greetings ("Sure!", "Great question!", "I'd be happy to...") +- Announcements ("Let me...", "I'll...", "Here's what I found...") +- Tutorials or background explanations the user didn't ask for +- Summaries ("In summary...", "To conclude...", "This covers...") +- Hedging ("I think", "probably", "might be") +- Puffery ("robust", "seamless", "elegant", "powerful", "flexible") + +Visual Structure + +Use these formats when applicable: +- File trees: `├── └──` ASCII format +- Comparisons: Markdown tables +- Flows: `A -> B -> C` diagrams +- Hierarchies: Indented bullet lists + +Examples + +BAD (prose first): +"The authentication flow works by first checking the token..." + +GOOD (diagram first): +``` +request -> auth.verify() -> permissions.check() -> handler +``` +See `middleware/auth.py:45`. + +--- + +BAD (over-explaining): +```python +def merge(a, b): + return sorted(a + b) +``` +This function takes two lists as parameters. It concatenates them using the + operator, then sorts the result using Python's built-in sorted() function which uses Timsort with O(n log n) complexity. The sorted list is returned. + +GOOD (minimal): +```python +def merge(a, b): + return sorted(a + b) +``` +O(n log n). diff --git a/vibe/core/proxy_setup.py b/vibe/core/proxy_setup.py new file mode 100644 index 0000000..3411437 --- /dev/null +++ b/vibe/core/proxy_setup.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from dotenv import dotenv_values, set_key, unset_key + +from vibe.core.paths.global_paths import GLOBAL_ENV_FILE + +SUPPORTED_PROXY_VARS: dict[str, str] = { + "HTTP_PROXY": "Proxy URL for HTTP requests", + "HTTPS_PROXY": "Proxy URL for HTTPS requests", + "ALL_PROXY": "Proxy URL for all requests (fallback)", + "NO_PROXY": "Comma-separated list of hosts to bypass proxy", + "SSL_CERT_FILE": "Path to custom SSL certificate file", + "SSL_CERT_DIR": "Path to directory containing SSL certificates", +} + + +class ProxySetupError(Exception): + pass + + +def get_current_proxy_settings() -> dict[str, str | None]: + if not GLOBAL_ENV_FILE.path.exists(): + return {key: None for key in SUPPORTED_PROXY_VARS} + + try: + env_vars = dotenv_values(GLOBAL_ENV_FILE.path) + return {key: env_vars.get(key) for key in SUPPORTED_PROXY_VARS} + except Exception: + return {key: None for key in SUPPORTED_PROXY_VARS} + + +def set_proxy_var(key: str, value: str) -> None: + key = key.upper() + if key not in SUPPORTED_PROXY_VARS: + raise ProxySetupError( + f"Unknown key '{key}'. Supported: {', '.join(SUPPORTED_PROXY_VARS.keys())}" + ) + + GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True) + set_key(GLOBAL_ENV_FILE.path, key, value) + + +def unset_proxy_var(key: str) -> None: + key = key.upper() + if key not in SUPPORTED_PROXY_VARS: + raise ProxySetupError( + f"Unknown key '{key}'. Supported: {', '.join(SUPPORTED_PROXY_VARS.keys())}" + ) + + if not GLOBAL_ENV_FILE.path.exists(): + return + + unset_key(GLOBAL_ENV_FILE.path, key) + + +def parse_proxy_command(args: str) -> tuple[str, str | None]: + args = args.strip() + if not args: + raise ProxySetupError("No key provided") + + parts = args.split(maxsplit=1) + key = parts[0].upper() + value = parts[1] if len(parts) > 1 else None + + return key, value diff --git a/vibe/core/session/session_loader.py b/vibe/core/session/session_loader.py index 6f9c2e8..bfba38c 100644 --- a/vibe/core/session/session_loader.py +++ b/vibe/core/session/session_loader.py @@ -1,8 +1,9 @@ from __future__ import annotations +from datetime import UTC, datetime import json from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypedDict from vibe.core.session.session_logger import MESSAGES_FILENAME, METADATA_FILENAME from vibe.core.types import LLMMessage @@ -11,6 +12,13 @@ if TYPE_CHECKING: from vibe.core.config import SessionLoggingConfig +class SessionInfo(TypedDict): + session_id: str + cwd: str + title: str | None + end_time: str | None + + class SessionLoader: @staticmethod def _is_valid_session(session_dir: Path) -> bool: @@ -106,6 +114,63 @@ class SessionLoader: short_id = session_id[:8] return list(save_dir.glob(f"{config.session_prefix}_*_{short_id}")) + @staticmethod + def _convert_to_utc_iso(date_str: str) -> str: + dt = datetime.fromisoformat(date_str) + if dt.tzinfo is None: + dt = dt.astimezone() + utc_dt = dt.astimezone(UTC) + return utc_dt.isoformat() + + @staticmethod + def list_sessions( + config: SessionLoggingConfig, cwd: str | None = None + ) -> list[SessionInfo]: + save_dir = Path(config.save_dir) + if not save_dir.exists(): + return [] + + pattern = f"{config.session_prefix}_*" + session_dirs = list(save_dir.glob(pattern)) + + sessions: list[SessionInfo] = [] + for session_dir in session_dirs: + if not SessionLoader._is_valid_session(session_dir): + continue + + metadata_path = session_dir / METADATA_FILENAME + try: + with metadata_path.open("r", encoding="utf-8") as f: + metadata = json.load(f) + except (OSError, json.JSONDecodeError): + continue + + session_id = metadata.get("session_id") + if not session_id: + continue + + environment = metadata.get("environment", {}) + session_cwd = environment.get("working_directory", "") + + if cwd is not None and session_cwd != cwd: + continue + + end_time = metadata.get("end_time") + if end_time: + try: + end_time = SessionLoader._convert_to_utc_iso(end_time) + except (ValueError, OSError): + end_time = None + + sessions.append({ + "session_id": session_id, + "cwd": session_cwd, + "title": metadata.get("title"), + "end_time": end_time, + }) + + return sessions + @staticmethod def load_session(filepath: Path) -> tuple[list[LLMMessage], dict[str, Any]]: # Load session messages from MESSAGES_FILENAME diff --git a/vibe/core/skills/manager.py b/vibe/core/skills/manager.py index a72e69f..58ae812 100644 --- a/vibe/core/skills/manager.py +++ b/vibe/core/skills/manager.py @@ -5,7 +5,7 @@ from logging import getLogger from pathlib import Path from typing import TYPE_CHECKING -from vibe.core.paths.config_paths import resolve_local_skills_dir +from vibe.core.paths.config_paths import resolve_local_skills_dirs from vibe.core.paths.global_paths import GLOBAL_SKILLS_DIR from vibe.core.skills.models import SkillInfo, SkillMetadata from vibe.core.skills.parser import SkillParseError, parse_frontmatter @@ -58,8 +58,7 @@ class SkillManager: if path.is_dir(): paths.append(path) - if (skills_dir := resolve_local_skills_dir(Path.cwd())) is not None: - paths.append(skills_dir) + paths.extend(resolve_local_skills_dirs(Path.cwd())) if GLOBAL_SKILLS_DIR.path.is_dir(): paths.append(GLOBAL_SKILLS_DIR.path) diff --git a/vibe/core/system_prompt.py b/vibe/core/system_prompt.py index 985afcb..549e2ed 100644 --- a/vibe/core/system_prompt.py +++ b/vibe/core/system_prompt.py @@ -11,7 +11,7 @@ import time from typing import TYPE_CHECKING from vibe.core.prompts import UtilityPrompt -from vibe.core.trusted_folders import TRUSTABLE_FILENAMES, trusted_folders_manager +from vibe.core.trusted_folders import AGENTS_MD_FILENAMES, trusted_folders_manager from vibe.core.utils import is_dangerous_directory, is_windows if TYPE_CHECKING: @@ -24,7 +24,7 @@ if TYPE_CHECKING: def _load_project_doc(workdir: Path, max_bytes: int) -> str: if not trusted_folders_manager.is_trusted(workdir): return "" - for name in TRUSTABLE_FILENAMES: + for name in AGENTS_MD_FILENAMES: path = workdir / name try: return path.read_text("utf-8", errors="ignore")[:max_bytes] diff --git a/vibe/core/llm/backend/__init__.py b/vibe/core/telemetry/__init__.py similarity index 100% rename from vibe/core/llm/backend/__init__.py rename to vibe/core/telemetry/__init__.py diff --git a/vibe/core/telemetry/send.py b/vibe/core/telemetry/send.py new file mode 100644 index 0000000..caa5e6e --- /dev/null +++ b/vibe/core/telemetry/send.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import os +from typing import TYPE_CHECKING, Any, Literal + +import httpx + +from vibe import __version__ +from vibe.core.config import Backend, VibeConfig +from vibe.core.llm.format import ResolvedToolCall +from vibe.core.utils import get_user_agent + +if TYPE_CHECKING: + from vibe.core.agent_loop import ToolDecision + +DATALAKE_EVENTS_URL = "https://codestral.mistral.ai/v1/datalake/events" + + +class TelemetryClient: + def __init__(self, config_getter: Callable[[], VibeConfig]) -> None: + self._config_getter = config_getter + self._client: httpx.AsyncClient | None = None + self._pending_tasks: set[asyncio.Task[Any]] = set() + + def _get_telemetry_user_agent(self) -> str: + try: + config = self._config_getter() + active_model = config.get_active_model() + provider = config.get_provider_for_model(active_model) + return get_user_agent(provider.backend) + except ValueError: + return get_user_agent(None) + + def _get_mistral_api_key(self) -> str | None: + """Get the current API key from the active provider. + + Only returns an API key if the provider is a Mistral provider + to avoid leaking third-party credentials to the telemetry endpoint. + """ + try: + config = self._config_getter() + model = config.get_active_model() + provider = config.get_provider_for_model(model) + if provider.backend != Backend.MISTRAL: + return None + env_var = provider.api_key_env_var + return os.getenv(env_var) if env_var else None + except ValueError: + return None + + def _is_enabled(self) -> bool: + """Check if telemetry is enabled in the current config.""" + try: + return self._config_getter().enable_telemetry + except ValueError: + return False + + @property + def client(self) -> httpx.AsyncClient: + if self._client is None: + self._client = httpx.AsyncClient( + timeout=httpx.Timeout(5.0), + limits=httpx.Limits(max_keepalive_connections=5, max_connections=10), + ) + return self._client + + def send_telemetry_event(self, event_name: str, properties: dict[str, Any]) -> None: + mistral_api_key = self._get_mistral_api_key() + if mistral_api_key is None or not self._is_enabled(): + return + user_agent = self._get_telemetry_user_agent() + + async def _send() -> None: + try: + await self.client.post( + DATALAKE_EVENTS_URL, + json={"event": event_name, "properties": properties}, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {mistral_api_key}", + "User-Agent": user_agent, + }, + ) + except Exception: + pass # Silently swallow all exceptions for fire-and-forget telemetry + + task = asyncio.create_task(_send()) + self._pending_tasks.add(task) + task.add_done_callback(self._pending_tasks.discard) + + async def aclose(self) -> None: + if self._pending_tasks: + await asyncio.gather(*self._pending_tasks, return_exceptions=True) + if self._client is not None: + await self._client.aclose() + self._client = None + + def _calculate_file_metrics( + self, + tool_call: ResolvedToolCall, + status: Literal["success", "failure", "skipped"], + result: dict[str, Any] | None = None, + ) -> tuple[int, int]: + nb_files_created = 0 + 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 + return nb_files_created, nb_files_modified + + def send_tool_call_finished( + self, + *, + tool_call: ResolvedToolCall, + status: Literal["success", "failure", "skipped"], + decision: ToolDecision | None, + agent_profile_name: str, + result: dict[str, Any] | None = None, + ) -> None: + verdict_value = decision.verdict.value if decision else None + approval_type_value = decision.approval_type.value if decision else None + + nb_files_created, nb_files_modified = self._calculate_file_metrics( + tool_call, status, result + ) + + payload = { + "tool_name": tool_call.tool_name, + "status": status, + "decision": verdict_value, + "approval_type": approval_type_value, + "agent_profile_name": agent_profile_name, + "nb_files_created": nb_files_created, + "nb_files_modified": nb_files_modified, + } + self.send_telemetry_event("vibe/tool_call_finished", payload) + + def send_user_copied_text(self, text: str) -> None: + payload = {"text_length": len(text)} + self.send_telemetry_event("vibe/user_copied_text", payload) + + def send_user_cancelled_action(self, action: str) -> None: + payload = {"action": action} + self.send_telemetry_event("vibe/user_cancelled_action", payload) + + def send_auto_compact_triggered(self) -> None: + payload = {} + self.send_telemetry_event("vibe/auto_compact_triggered", payload) + + def send_slash_command_used( + self, command: str, command_type: Literal["builtin", "skill"] + ) -> None: + payload = {"command": command.lstrip("/"), "command_type": command_type} + self.send_telemetry_event("vibe/slash_command_used", payload) + + def send_new_session( + self, + has_agents_md: bool, + nb_skills: int, + nb_mcp_servers: int, + nb_models: int, + entrypoint: Literal["cli", "acp", "programmatic"], + ) -> None: + payload = { + "has_agents_md": has_agents_md, + "nb_skills": nb_skills, + "nb_mcp_servers": nb_mcp_servers, + "nb_models": nb_models, + "entrypoint": entrypoint, + "version": __version__, + } + self.send_telemetry_event("vibe/new_session", payload) + + def send_onboarding_api_key_added(self) -> None: + self.send_telemetry_event( + "vibe/onboarding_api_key_added", {"version": __version__} + ) diff --git a/vibe/core/tools/mcp.py b/vibe/core/tools/mcp.py index 8aa3aea..374bf59 100644 --- a/vibe/core/tools/mcp.py +++ b/vibe/core/tools/mcp.py @@ -1,10 +1,14 @@ from __future__ import annotations from collections.abc import AsyncGenerator +import contextlib from datetime import timedelta import hashlib +from logging import getLogger +import os from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar +import threading +from typing import TYPE_CHECKING, Any, ClassVar, TextIO from mcp import ClientSession from mcp.client.stdio import StdioServerParameters, stdio_client @@ -24,6 +28,37 @@ from vibe.core.types import ToolStreamEvent if TYPE_CHECKING: from vibe.core.types import ToolCallEvent, ToolResultEvent +logger = getLogger("vibe") + + +def _stderr_logger_thread(read_fd: int) -> None: + with open(read_fd, "rb") as f: + for line in iter(f.readline, b""): + decoded = line.decode("utf-8", errors="replace").rstrip() + if decoded: + logger.debug(f"[MCP stderr] {decoded}") + + +@contextlib.asynccontextmanager +async def _mcp_stderr_capture() -> AsyncGenerator[TextIO, None]: + r, w = os.pipe() + errlog = None + thread_started = False + try: + thread = threading.Thread(target=_stderr_logger_thread, args=(r,), daemon=True) + thread.start() + thread_started = True + errlog = os.fdopen(w, "w") + yield errlog + finally: + if errlog is not None: + errlog.close() + elif thread_started: + os.close(w) + else: + os.close(r) + os.close(w) + class _OpenArgs(BaseModel): model_config = ConfigDict(extra="allow") @@ -240,11 +275,14 @@ async def list_tools_stdio( ) -> list[RemoteTool]: params = StdioServerParameters(command=command[0], args=command[1:], env=env) timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None - async with stdio_client(params) as (read, write): - async with ClientSession(read, write, read_timeout_seconds=timeout) as session: - await session.initialize() - tools_resp = await session.list_tools() - return [RemoteTool.model_validate(t) for t in tools_resp.tools] + async with ( + _mcp_stderr_capture() as errlog, + stdio_client(params, errlog=errlog) as (read, write), + ClientSession(read, write, read_timeout_seconds=timeout) as session, + ): + await session.initialize() + tools_resp = await session.list_tools() + return [RemoteTool.model_validate(t) for t in tools_resp.tools] async def call_tool_stdio( @@ -261,15 +299,16 @@ async def call_tool_stdio( timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None ) call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None - async with stdio_client(params) as (read, write): - async with ClientSession( - read, write, read_timeout_seconds=init_timeout - ) as session: - await session.initialize() - result = await session.call_tool( - tool_name, arguments, read_timeout_seconds=call_timeout - ) - return _parse_call_result("stdio:" + " ".join(command), tool_name, result) + async with ( + _mcp_stderr_capture() as errlog, + stdio_client(params, errlog=errlog) as (read, write), + ClientSession(read, write, read_timeout_seconds=init_timeout) as session, + ): + await session.initialize() + result = await session.call_tool( + tool_name, arguments, read_timeout_seconds=call_timeout + ) + return _parse_call_result("stdio:" + " ".join(command), tool_name, result) def create_mcp_stdio_proxy_tool_class( diff --git a/vibe/core/trusted_folders.py b/vibe/core/trusted_folders.py index 0c5e4bc..969aa8b 100644 --- a/vibe/core/trusted_folders.py +++ b/vibe/core/trusted_folders.py @@ -7,16 +7,19 @@ import tomli_w from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE -TRUSTABLE_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"] +AGENTS_MD_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"] + + +def has_agents_md_file(path: Path) -> bool: + return any((path / name).exists() for name in AGENTS_MD_FILENAMES) def has_trustable_content(path: Path) -> bool: - if (path / ".vibe").exists(): - return True - for name in TRUSTABLE_FILENAMES: - if (path / name).exists(): - return True - return False + return ( + (path / ".vibe").exists() + or (path / ".agents").exists() + or has_agents_md_file(path) + ) class TrustedFoldersManager: diff --git a/vibe/core/types.py b/vibe/core/types.py index 2fd4cd7..47decf4 100644 --- a/vibe/core/types.py +++ b/vibe/core/types.py @@ -191,6 +191,7 @@ class LLMMessage(BaseModel): role: Role content: Content | None = None reasoning_content: Content | None = None + reasoning_signature: str | None = None tool_calls: list[ToolCall] | None = None name: str | None = None tool_call_id: str | None = None @@ -210,6 +211,7 @@ class LLMMessage(BaseModel): "role": role, "content": getattr(v, "content", ""), "reasoning_content": getattr(v, "reasoning_content", None), + "reasoning_signature": getattr(v, "reasoning_signature", None), "tool_calls": getattr(v, "tool_calls", None), "name": getattr(v, "name", None), "tool_call_id": getattr(v, "tool_call_id", None), @@ -238,6 +240,12 @@ class LLMMessage(BaseModel): if not reasoning_content: reasoning_content = None + reasoning_signature = (self.reasoning_signature or "") + ( + other.reasoning_signature or "" + ) + if not reasoning_signature: + reasoning_signature = None + tool_calls_map = OrderedDict[int, ToolCall]() for tool_calls in [self.tool_calls or [], other.tool_calls or []]: for tc in tool_calls: @@ -263,6 +271,7 @@ class LLMMessage(BaseModel): role=self.role, content=content, reasoning_content=reasoning_content, + reasoning_signature=reasoning_signature, tool_calls=list(tool_calls_map.values()) or None, name=self.name, tool_call_id=self.tool_call_id, diff --git a/vibe/core/utils.py b/vibe/core/utils.py index c5a1eaa..a8eabdb 100644 --- a/vibe/core/utils.py +++ b/vibe/core/utils.py @@ -8,6 +8,8 @@ from enum import Enum, auto from fnmatch import fnmatch import functools import logging +from logging.handlers import RotatingFileHandler +import os from pathlib import Path import re import sys @@ -139,16 +141,56 @@ def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]: LOG_DIR.path.mkdir(parents=True, exist_ok=True) -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s %(message)s", - handlers=[logging.FileHandler(LOG_FILE.path, "a", "utf-8")], -) - logger = logging.getLogger("vibe") -def get_user_agent(backend: Backend) -> str: +class StructuredLogFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + timestamp = datetime.fromtimestamp(record.created, tz=UTC).isoformat() + ppid = os.getppid() + pid = os.getpid() + level = record.levelname + message = record.getMessage().replace("\\", "\\\\").replace("\n", "\\n") + + line = f"{timestamp} {ppid} {pid} {level} {message}" + + if record.exc_info: + exc_text = self.formatException(record.exc_info).replace("\n", "\\n") + line = f"{line} {exc_text}" + + return line + + +def apply_logging_config(target_logger: logging.Logger) -> None: + LOG_DIR.path.mkdir(parents=True, exist_ok=True) + + max_bytes = int(os.environ.get("LOG_MAX_BYTES", 10 * 1024 * 1024)) + + if os.environ.get("DEBUG_MODE") == "true": + log_level_str = "DEBUG" + else: + log_level_str = os.environ.get("LOG_LEVEL", "WARNING").upper() + valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + if log_level_str not in valid_levels: + log_level_str = "WARNING" + + handler = RotatingFileHandler( + LOG_FILE.path, maxBytes=max_bytes, backupCount=0, encoding="utf-8" + ) + handler.setFormatter(StructuredLogFormatter()) + log_level = getattr(logging, log_level_str, logging.WARNING) + handler.setLevel(log_level) + + # Make sure the logger is not gating logs + target_logger.setLevel(logging.DEBUG) + + target_logger.addHandler(handler) + + +apply_logging_config(logger) + + +def get_user_agent(backend: Backend | None) -> str: user_agent = f"Mistral-Vibe/{__version__}" if backend == Backend.MISTRAL: mistral_sdk_prefix = "mistral-client-python/" diff --git a/vibe/setup/onboarding/screens/api_key.py b/vibe/setup/onboarding/screens/api_key.py index 9d3a50a..c5d35e4 100644 --- a/vibe/setup/onboarding/screens/api_key.py +++ b/vibe/setup/onboarding/screens/api_key.py @@ -13,8 +13,9 @@ from textual.widgets import Input, Link, Static from vibe.cli.clipboard import copy_selection_to_clipboard from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic -from vibe.core.config import VibeConfig +from vibe.core.config import Backend, VibeConfig from vibe.core.paths.global_paths import GLOBAL_ENV_FILE +from vibe.core.telemetry.send import TelemetryClient from vibe.setup.onboarding.base import OnboardingScreen PROVIDER_HELP = { @@ -129,6 +130,12 @@ class ApiKeyScreen(OnboardingScreen): except OSError as err: self.app.exit(f"save_error:{err}") return + if self.provider.backend == Backend.MISTRAL: + try: + telemetry = TelemetryClient(config_getter=VibeConfig) + telemetry.send_onboarding_api_key_added() + except Exception: + pass # Telemetry is fire-and-forget; don't fail onboarding self.app.exit("completed") def on_mouse_up(self, event: MouseUp) -> None: diff --git a/vibe/whats_new.md b/vibe/whats_new.md index 6c5a023..5edd388 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -1,9 +1,5 @@ -# What's New in 2.1.0 +# What's New in 2.2.0 -- **UI redesign** — Refreshed interface with a cleaner layout and better performance when streaming. +- **Agent Skills standard** — Vibe now discovers skills from `.agents/skills/` (agentskills.io) as well as `.vibe/skills/`. -- **Themes removed** — Application themes have been removed; the UI now follows your terminal’s theme (colors and appearance). - -- **Clipboard** — You can now disable autocopying in /config, and copy with Ctrl+Shift+C or Cmd+C (in terminals that support it). - -- **Performance** — Various optimizations throughout the app for a more responsive UI. +Optional: usage and tool events are sent to our datalake to improve the product if you have a valid Mistral API key; set `disable_telemetry = true` in config to opt out.