From eb580209d4f089d305a9cac96abe517f35db4613 Mon Sep 17 00:00:00 2001 From: Mathias Gesbert Date: Mon, 23 Mar 2026 18:45:21 +0100 Subject: [PATCH] v2.6.0 (#524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Clément Drouin Co-authored-by: Clément Sirieix Co-authored-by: Gauthier Guinet <43207538+Gguinet@users.noreply.github.com> Co-authored-by: Kim-Adeline Miguel Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Quentin Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com> Co-authored-by: Simon Van de Kerckhove Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: angelapopopo Co-authored-by: Mistral Vibe --- .github/workflows/build-and-upload.yml | 33 +- .vscode/launch.json | 2 +- CHANGELOG.md | 37 + distribution/zed/extension.toml | 14 +- pyinstaller/runtime_hook_truststore.py | 6 + pyproject.toml | 13 +- scripts/prepare_release.py | 79 +- tests/acp/test_acp.py | 36 + tests/acp/test_acp_entrypoint_smoke.py | 254 ++++++ tests/acp/test_initialize.py | 4 +- tests/acp/test_load_session.py | 24 +- tests/acp/test_new_session.py | 20 +- tests/acp/test_set_config_option.py | 12 +- tests/acp/test_usage_update.py | 254 ++++++ tests/acp/test_utils.py | 70 +- tests/audio_player/test_audio_player.py | 206 +++++ tests/backend/test_backend.py | 97 ++- .../cli/plan_offer/test_decide_plan_offer.py | 3 +- tests/cli/test_commands.py | 2 +- tests/cli/test_feedback_bar.py | 77 ++ tests/cli/test_ui_config_and_model_picker.py | 284 +++++++ tests/cli/test_ui_session_exit.py | 45 ++ tests/conftest.py | 22 +- tests/core/test_config_otel.py | 127 +++ tests/core/test_local_config_walk.py | 135 ++++ tests/core/test_slug.py | 2 +- tests/core/test_telemetry_send.py | 44 +- tests/core/test_tts_config.py | 107 +++ tests/core/test_utils.py | 45 ++ tests/e2e/common.py | 4 +- tests/e2e/conftest.py | 12 +- tests/e2e/test_cli_tui_session_exit.py | 138 ++++ tests/mock/mock_backend_factory.py | 2 +- tests/session/test_session_loader.py | 5 +- ...izontal_scrolling_for_long_code_blocks.svg | 2 +- .../test_snapshot_config_escape_closes.svg | 200 +++++ .../test_snapshot_config_initial.svg | 204 +++++ .../test_snapshot_config_navigate_down.svg | 204 +++++ .../test_snapshot_config_toggle_autocopy.svg | 204 +++++ .../test_snapshot_feedback_bar_visible.svg | 202 +++++ ...t_snapshot_model_picker_escape_cancels.svg | 200 +++++ .../test_snapshot_model_picker_initial.svg | 203 +++++ ...st_snapshot_model_picker_navigate_down.svg | 203 +++++ ...ot_model_picker_select_different_model.svg | 200 +++++ ..._snapshot_narrator_idle_after_speaking.svg | 201 +++++ .../test_snapshot_narrator_speaking.svg | 203 +++++ .../test_snapshot_narrator_summarizing.svg | 203 +++++ .../test_snapshot_voice_disable.svg | 12 +- .../test_snapshot_voice_enable.svg | 12 +- .../snapshots/test_ui_snapshot_config_app.py | 63 ++ .../test_ui_snapshot_feedback_bar.py | 46 ++ .../test_ui_snapshot_model_picker.py | 95 +++ .../test_ui_snapshot_narrator_flow.py | 148 ++++ .../snapshots/test_ui_snapshot_voice_mode.py | 18 +- tests/stubs/fake_audio_player.py | 36 + tests/stubs/fake_client.py | 4 +- tests/stubs/fake_tts_client.py | 21 + tests/test_agent_backend.py | 141 +++- tests/test_agent_observer_streaming.py | 19 +- tests/test_agent_stats.py | 6 +- tests/test_agent_tool_call.py | 22 +- tests/test_agents.py | 31 +- tests/test_cli_programmatic_preload.py | 3 +- tests/test_reasoning_content.py | 6 +- tests/test_tracing.py | 371 +++++++++ tests/test_turn_summary.py | 349 ++++++++ tests/tools/test_arity.py | 54 ++ tests/tools/test_bash.py | 61 +- tests/tools/test_granular_permissions.py | 750 ++++++++++++++++++ tests/tools/test_invoke_context.py | 10 +- tests/tools/test_manager_get_tool_config.py | 23 +- tests/tools/test_skill.py | 217 +++++ tests/tools/test_task.py | 13 +- tests/tools/test_websearch.py | 3 +- tests/tools/test_wildcard_match.py | 66 ++ tests/transcribe/test_transcribe_client.py | 14 +- tests/tts/test_tts_client.py | 100 +++ tests/voice_manager/test_telemetry.py | 44 + tests/voice_manager/test_voice_manager.py | 199 ++++- uv.lock | 116 ++- vibe-acp.spec | 20 +- vibe/__init__.py | 2 +- vibe/acp/acp_agent_loop.py | 153 +++- vibe/acp/entrypoint.py | 12 +- vibe/acp/utils.py | 78 +- vibe/cli/cli.py | 13 +- vibe/cli/commands.py | 11 +- vibe/cli/entrypoint.py | 5 +- vibe/cli/plan_offer/decide_plan_offer.py | 3 +- vibe/cli/terminal_setup.py | 4 +- vibe/cli/textual_ui/app.py | 404 ++++++++-- vibe/cli/textual_ui/app.tcss | 107 ++- vibe/cli/textual_ui/constants.py | 11 + vibe/cli/textual_ui/external_editor.py | 4 +- vibe/cli/textual_ui/handlers/event_handler.py | 10 +- vibe/cli/textual_ui/session_exit.py | 25 + vibe/cli/textual_ui/widgets/approval_app.py | 25 +- .../widgets/chat_input/text_area.py | 19 + vibe/cli/textual_ui/widgets/config_app.py | 192 ++--- vibe/cli/textual_ui/widgets/feedback_bar.py | 69 ++ vibe/cli/textual_ui/widgets/loading.py | 9 +- vibe/cli/textual_ui/widgets/model_picker.py | 75 ++ .../cli/textual_ui/widgets/narrator_status.py | 56 ++ vibe/cli/textual_ui/widgets/voice_app.py | 173 ++++ vibe/cli/textual_ui/widgets/vscode_compat.py | 2 +- vibe/cli/turn_summary/__init__.py | 20 + vibe/cli/turn_summary/noop.py | 30 + vibe/cli/turn_summary/port.py | 43 + vibe/cli/turn_summary/tracker.py | 109 +++ vibe/cli/turn_summary/utils.py | 30 + vibe/cli/update_notifier/whats_new.py | 3 +- vibe/cli/voice_manager/telemetry.py | 30 + vibe/cli/voice_manager/voice_manager.py | 91 ++- vibe/core/agent_loop.py | 170 +++- vibe/core/agents/models.py | 19 +- vibe/core/audio_player/__init__.py | 21 + vibe/core/audio_player/audio_player.py | 130 +++ vibe/core/audio_player/audio_player_port.py | 40 + vibe/core/audio_player/utils.py | 12 + vibe/core/audio_recorder/audio_recorder.py | 1 + .../file_indexer/ignore_rules.py | 4 +- vibe/core/config/__init__.py | 14 +- vibe/core/config/_settings.py | 149 +++- .../config/harness_files/_harness_manager.py | 7 +- vibe/core/llm/backend/factory.py | 2 +- vibe/core/llm/backend/mistral.py | 16 +- vibe/core/llm/backend/reasoning_adapter.py | 5 +- vibe/core/paths/__init__.py | 8 +- vibe/core/paths/_local_config_walk.py | 122 ++- vibe/core/plan_session.py | 2 +- vibe/core/prompts/__init__.py | 4 +- vibe/core/prompts/cli.md | 10 +- vibe/core/prompts/lean.md | 8 - vibe/core/prompts/turn_summary.md | 10 + vibe/core/session/session_loader.py | 8 +- vibe/core/session/session_logger.py | 9 +- vibe/core/session/session_migration.py | 4 +- vibe/core/skills/manager.py | 3 +- vibe/core/system_prompt.py | 2 +- vibe/core/telemetry/send.py | 32 +- vibe/core/tools/arity.py | 158 ++++ vibe/core/tools/base.py | 14 +- vibe/core/tools/builtins/bash.py | 165 +++- vibe/core/tools/builtins/exit_plan_mode.py | 3 +- vibe/core/tools/builtins/grep.py | 19 +- vibe/core/tools/builtins/prompts/skill.md | 19 + vibe/core/tools/builtins/read_file.py | 24 +- vibe/core/tools/builtins/search_replace.py | 18 +- vibe/core/tools/builtins/skill.py | 139 ++++ vibe/core/tools/builtins/task.py | 7 +- vibe/core/tools/builtins/webfetch.py | 38 +- vibe/core/tools/builtins/websearch.py | 3 +- vibe/core/tools/builtins/write_file.py | 9 +- vibe/core/tools/manager.py | 5 +- vibe/core/tools/permissions.py | 32 + vibe/core/tools/utils.py | 87 +- vibe/core/tracing.py | 137 ++++ .../transcribe/mistral_transcribe_client.py | 2 +- .../core/transcribe/transcribe_client_port.py | 2 +- vibe/core/trusted_folders.py | 7 +- vibe/core/tts/__init__.py | 7 + vibe/core/tts/factory.py | 15 + vibe/core/tts/mistral_tts_client.py | 44 + vibe/core/tts/tts_client_port.py | 19 + vibe/core/types.py | 23 +- vibe/core/utils.py | 343 -------- vibe/core/utils/__init__.py | 55 ++ vibe/core/utils/concurrency.py | 59 ++ vibe/core/utils/display.py | 13 + vibe/core/utils/http.py | 19 + vibe/core/utils/io.py | 29 + vibe/core/utils/matching.py | 35 + vibe/core/utils/paths.py | 42 + vibe/core/utils/platform.py | 7 + vibe/core/utils/retry.py | 103 +++ vibe/core/{ => utils}/slug.py | 0 vibe/core/utils/tags.py | 80 ++ vibe/core/utils/time.py | 7 + vibe/setup/onboarding/screens/api_key.py | 3 +- vibe/whats_new.md | 8 +- 180 files changed, 11136 insertions(+), 1030 deletions(-) create mode 100644 pyinstaller/runtime_hook_truststore.py create mode 100644 tests/acp/test_acp_entrypoint_smoke.py create mode 100644 tests/acp/test_usage_update.py create mode 100644 tests/audio_player/test_audio_player.py create mode 100644 tests/cli/test_feedback_bar.py create mode 100644 tests/cli/test_ui_config_and_model_picker.py create mode 100644 tests/cli/test_ui_session_exit.py create mode 100644 tests/core/test_config_otel.py create mode 100644 tests/core/test_local_config_walk.py create mode 100644 tests/core/test_tts_config.py create mode 100644 tests/e2e/test_cli_tui_session_exit.py create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_escape_closes.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_initial.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_navigate_down.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_toggle_autocopy.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_feedback_bar/test_snapshot_feedback_bar_visible.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_escape_cancels.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_initial.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_navigate_down.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_select_different_model.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_idle_after_speaking.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_speaking.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_summarizing.svg create mode 100644 tests/snapshots/test_ui_snapshot_config_app.py create mode 100644 tests/snapshots/test_ui_snapshot_feedback_bar.py create mode 100644 tests/snapshots/test_ui_snapshot_model_picker.py create mode 100644 tests/snapshots/test_ui_snapshot_narrator_flow.py create mode 100644 tests/stubs/fake_audio_player.py create mode 100644 tests/stubs/fake_tts_client.py create mode 100644 tests/test_tracing.py create mode 100644 tests/test_turn_summary.py create mode 100644 tests/tools/test_arity.py create mode 100644 tests/tools/test_granular_permissions.py create mode 100644 tests/tools/test_skill.py create mode 100644 tests/tools/test_wildcard_match.py create mode 100644 tests/tts/test_tts_client.py create mode 100644 tests/voice_manager/test_telemetry.py create mode 100644 vibe/cli/textual_ui/constants.py create mode 100644 vibe/cli/textual_ui/session_exit.py create mode 100644 vibe/cli/textual_ui/widgets/feedback_bar.py create mode 100644 vibe/cli/textual_ui/widgets/model_picker.py create mode 100644 vibe/cli/textual_ui/widgets/narrator_status.py create mode 100644 vibe/cli/textual_ui/widgets/voice_app.py create mode 100644 vibe/cli/turn_summary/__init__.py create mode 100644 vibe/cli/turn_summary/noop.py create mode 100644 vibe/cli/turn_summary/port.py create mode 100644 vibe/cli/turn_summary/tracker.py create mode 100644 vibe/cli/turn_summary/utils.py create mode 100644 vibe/cli/voice_manager/telemetry.py create mode 100644 vibe/core/audio_player/__init__.py create mode 100644 vibe/core/audio_player/audio_player.py create mode 100644 vibe/core/audio_player/audio_player_port.py create mode 100644 vibe/core/audio_player/utils.py create mode 100644 vibe/core/prompts/turn_summary.md create mode 100644 vibe/core/tools/arity.py create mode 100644 vibe/core/tools/builtins/prompts/skill.md create mode 100644 vibe/core/tools/builtins/skill.py create mode 100644 vibe/core/tools/permissions.py create mode 100644 vibe/core/tracing.py create mode 100644 vibe/core/tts/__init__.py create mode 100644 vibe/core/tts/factory.py create mode 100644 vibe/core/tts/mistral_tts_client.py create mode 100644 vibe/core/tts/tts_client_port.py delete mode 100644 vibe/core/utils.py create mode 100644 vibe/core/utils/__init__.py create mode 100644 vibe/core/utils/concurrency.py create mode 100644 vibe/core/utils/display.py create mode 100644 vibe/core/utils/http.py create mode 100644 vibe/core/utils/io.py create mode 100644 vibe/core/utils/matching.py create mode 100644 vibe/core/utils/paths.py create mode 100644 vibe/core/utils/platform.py create mode 100644 vibe/core/utils/retry.py rename vibe/core/{ => utils}/slug.py (100%) create mode 100644 vibe/core/utils/tags.py create mode 100644 vibe/core/utils/time.py diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml index e75b1f6..7c63ad5 100644 --- a/.github/workflows/build-and-upload.yml +++ b/.github/workflows/build-and-upload.yml @@ -64,10 +64,8 @@ jobs: enable-cache: true cache-dependency-glob: "uv.lock" - - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.12" + - name: Install Python + run: uv python install 3.12 - name: Sync dependencies run: uv sync --no-dev --group build @@ -86,19 +84,20 @@ jobs: shell: pwsh run: python -c "import subprocess; version = subprocess.check_output(['uv', 'version']).decode().split()[1]; print(f'version={version}')" >> $env:GITHUB_OUTPUT - - name: Upload binary as artifact (Unix) - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + - name: Smoke test bundled binary (Unix) if: ${{ matrix.os != 'windows' }} - with: - name: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.get_version_unix.outputs.version }} - path: dist/vibe-acp + run: ./dist/vibe-acp-dir/vibe-acp --version - - name: Upload binary as artifact (Windows) - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + - name: Smoke test bundled binary (Windows) if: ${{ matrix.os == 'windows' }} + shell: pwsh + run: .\dist\vibe-acp-dir\vibe-acp.exe --version + + - name: Upload binary as artifact + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: - name: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.get_version_windows.outputs.version }} - path: dist/vibe-acp.exe + name: vibe-acp-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.os == 'windows' && steps.get_version_windows.outputs.version || steps.get_version_unix.outputs.version }} + path: dist/vibe-acp-dir/ attach-to-release: needs: build-and-upload @@ -117,11 +116,9 @@ jobs: mkdir release-assets for dir in artifacts/*; do name=$(basename "$dir") - if [ -f "$dir/vibe-acp" ]; then - chmod +x "$dir/vibe-acp" - zip -j "release-assets/${name}.zip" "$dir/vibe-acp" - elif [ -f "$dir/vibe-acp.exe" ]; then - zip -j "release-assets/${name}.zip" "$dir/vibe-acp.exe" + if [ -f "$dir/vibe-acp" ] || [ -f "$dir/vibe-acp.exe" ]; then + chmod -f +x "$dir/vibe-acp" 2>/dev/null || true + (cd "$dir" && zip -r "../../release-assets/${name}.zip" .) fi done diff --git a/.vscode/launch.json b/.vscode/launch.json index b137122..49574d9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,5 +1,5 @@ { - "version": "2.5.0", + "version": "2.6.0", "configurations": [ { "name": "ACP Server", diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c49f8c..d06575e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ 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.6.0] - 2026-03-23 + +### Added + +- OTEL tracing support for observability +- Skill tool for managing task lists and workflows +- Text-to-speech (TTS) functionality +- Standalone --resume command for session picker +- BFS for vibe folders to improve startup performance +- List-based model picker for /model command +- is_user_prompt flag to Mistral metadata header +- Correlation ID in user feedback calls +- Current date added to system prompt in vibe-work +- TypeScript type inference for large tool outputs in vibe-work-harness + +### Changed + +- Updated agent-client-protocol to 0.9.0a1 +- Changed inline code color from yellow to green +- Removed "You have no internet access" from CLI prompt +- Fine-grained permission system improvements +- Inject system certs into vibe-acp frozen binary via truststore + +### Fixed + +- Streaming for currently streamed message when switching agents +- Proper UI updates when tools switch current agents +- Space key functionality when holding shift +- Empty TextChunk not appended when reasoning has no text content +- Messages removed from user feedback event +- Bash allowlist/denylist activation on Windows +- Improved scrolling performance +- ACP error handling in webview +- Context usage updates sent via ACP +- Include `exit_plan_mode` tool only in plan mode + + ## [2.5.0] - 2026-03-16 ### Added diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index 8255175..ae97f41 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.5.0" +version = "2.6.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.5.0/vibe-acp-darwin-aarch64-2.5.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.0/vibe-acp-darwin-aarch64-2.6.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.5.0/vibe-acp-darwin-x86_64-2.5.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.0/vibe-acp-darwin-x86_64-2.6.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.5.0/vibe-acp-linux-aarch64-2.5.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.0/vibe-acp-linux-aarch64-2.6.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.5.0/vibe-acp-linux-x86_64-2.5.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.0/vibe-acp-linux-x86_64-2.6.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.5.0/vibe-acp-windows-aarch64-2.5.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.0/vibe-acp-windows-aarch64-2.6.0.zip" cmd = "./vibe-acp.exe" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.5.0/vibe-acp-windows-x86_64-2.5.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.6.0/vibe-acp-windows-x86_64-2.6.0.zip" cmd = "./vibe-acp.exe" diff --git a/pyinstaller/runtime_hook_truststore.py b/pyinstaller/runtime_hook_truststore.py new file mode 100644 index 0000000..14311b0 --- /dev/null +++ b/pyinstaller/runtime_hook_truststore.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +import truststore + +# Inject system certificates into ssl before the frozen app starts. +truststore.inject_into_ssl() diff --git a/pyproject.toml b/pyproject.toml index e86878a..25a9b10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.5.0" +version = "2.6.0" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" @@ -27,7 +27,7 @@ classifiers = [ "Topic :: Utilities", ] dependencies = [ - "agent-client-protocol==0.8.1", + "agent-client-protocol==0.9.0a1", "anyio>=4.12.0", "cachetools>=5.5.0", "cryptography>=44.0.0,<=46.0.3", @@ -39,6 +39,10 @@ dependencies = [ "markdownify>=1.2.2", "mcp>=1.14.0", "mistralai==2.0.0", + "opentelemetry-api>=1.39.1", + "opentelemetry-exporter-otlp-proto-http>=1.39.1", + "opentelemetry-sdk>=1.39.1", + "opentelemetry-semantic-conventions>=0.60b1", "packaging>=24.1", "pexpect>=4.9.0", "pydantic>=2.12.4", @@ -103,18 +107,21 @@ dev = [ "vulture>=2.14", ] -build = ["pyinstaller>=6.17.0"] +build = ["pyinstaller>=6.17.0", "truststore>=0.10.4"] [tool.pyright] pythonVersion = "3.12" reportMissingTypeStubs = false reportPrivateImportUsage = false + include = ["vibe/**/*.py", "tests/**/*.py"] +exclude = ["pyinstaller/"] venvPath = "." venv = ".venv" [tool.ruff] include = ["vibe/**/*.py", "tests/**/*.py"] +exclude = ["pyinstaller/"] line-length = 88 target-version = "py312" preview = true diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py index a8de360..fcc93ec 100755 --- a/scripts/prepare_release.py +++ b/scripts/prepare_release.py @@ -130,7 +130,9 @@ def create_release_branch(version: str) -> None: print(f"Created and switched to branch {branch_name}") -def cherry_pick_commits(previous_version: str, current_version: str) -> None: +def cherry_pick_commits( + previous_version: str, current_version: str, squash: bool +) -> None: previous_tag = f"v{previous_version}-private" current_tag = f"v{current_version}-private" @@ -150,6 +152,53 @@ def cherry_pick_commits(previous_version: str, current_version: str) -> None: run_git_command("cherry-pick", f"{previous_tag}..{current_tag}") print("Successfully cherry-picked all commits") + if squash: + squash_commits(previous_version, current_version, previous_tag, current_tag) + + +def squash_commits( + previous_version: str, current_version: str, previous_tag: str, current_tag: str +) -> None: + print("Squashing commits into a single release commit...") + run_git_command("reset", "--soft", f"v{previous_version}") + + # Get all contributors between previous and current private tags + result = run_git_command( + "log", + f"{previous_tag}..{current_tag}", + "--format=%aN <%aE>", + capture_output=True, + ) + contributors = result.stdout.strip().split("\n") + + # Get current user + current_user_result = run_git_command("config", "user.email", capture_output=True) + current_user_email = current_user_result.stdout.strip() + + # Filter out current user and create co-authored lines + vibe_marker = "vibe@mistral.ai" + unique_coauthors = { + f"Co-authored-by: {contributor}" + for contributor in contributors + if contributor + and current_user_email not in contributor + and vibe_marker not in contributor + } + + # Add Mistral Vibe as co-author + coauthored_lines = sorted(unique_coauthors) + [ + "Co-authored-by: Mistral Vibe " + ] + + # Create commit message + commit_message = f"v{current_version}\n" + for line in coauthored_lines: + commit_message += f"\n{line}" + + # Create the commit + run_git_command("commit", "-m", commit_message) + print("Successfully created release commit with co-authors") + def get_commits_summary(previous_version: str, current_version: str) -> str: previous_tag = f"v{previous_version}-private" @@ -182,6 +231,7 @@ def print_summary( previous_version: str, commits_summary: str, changelog_entry: str, + squash: bool, ) -> None: print("\n" + "=" * 80) print("RELEASE PREPARATION SUMMARY") @@ -204,14 +254,15 @@ def print_summary( print(changelog_entry) print("\n" + "-" * 80) - print("NEXT STEPS") - print("-" * 80) - print( - f"To review/edit commits before publishing, use interactive rebase:\n" - f" git rebase -i v{previous_version}" - ) + if not squash: + print("NEXT STEPS") + print("-" * 80) + print( + f"To review/edit commits before publishing, use interactive rebase:\n" + f" git rebase -i v{previous_version}" + ) - print("\n" + "-" * 80) + print("\n" + "-" * 80) print("REMINDERS") print("-" * 80) print("Before publishing the release:") @@ -231,9 +282,17 @@ def main() -> None: ) parser.add_argument("version", help="Version to prepare release for (e.g., 1.1.3)") + parser.add_argument( + "--no-squash", + action="store_false", + dest="squash", + default=True, + help="Disable squashing of commits into a single release commit", + ) args = parser.parse_args() current_version = args.version + squash = args.squash try: # Step 1: Ensure public remote exists @@ -264,7 +323,7 @@ def main() -> None: create_release_branch(current_version) # Step 7: Cherry-pick commits - cherry_pick_commits(previous_version, current_version) + cherry_pick_commits(previous_version, current_version, squash) # Step 8: Get summary information commits_summary = get_commits_summary(previous_version, current_version) @@ -272,7 +331,7 @@ def main() -> None: # Step 9: Print summary print_summary( - current_version, previous_version, commits_summary, changelog_entry + current_version, previous_version, commits_summary, changelog_entry, squash ) except Exception as e: diff --git a/tests/acp/test_acp.py b/tests/acp/test_acp.py index 026e92d..619bff8 100644 --- a/tests/acp/test_acp.py +++ b/tests/acp/test_acp.py @@ -759,6 +759,42 @@ class TestToolCallStructure: ) assert rejected_tool_call is not None + @pytest.mark.asyncio + async def test_permission_options_include_granular_labels_for_bash( + self, vibe_home_dir: Path + ) -> None: + """Bash 'npm install foo' should produce granular labels in permission options.""" + custom_results = [ + mock_llm_chunk( + tool_calls=[ + ToolCall( + function=FunctionCall( + name="bash", arguments='{"command":"npm install foo"}' + ), + type="function", + index=0, + ) + ] + ), + mock_llm_chunk(content="Done"), + ] + mock_env = get_mocking_env(custom_results) + async for process in get_acp_agent_loop_process( + mock_env=mock_env, vibe_home=vibe_home_dir + ): + permission_request = await start_session_with_request_permission( + process, "Run npm install foo" + ) + assert permission_request.params is not None + + # Verify "Allow always" option includes the pattern label + allow_always = next( + o + for o in permission_request.params.options + if o.option_id == ToolOption.ALLOW_ALWAYS + ) + assert "npm install *" in allow_always.name + @pytest.mark.skip(reason="Long running tool call updates are not implemented yet") @pytest.mark.asyncio async def test_tool_call_in_progress_update_structure( diff --git a/tests/acp/test_acp_entrypoint_smoke.py b/tests/acp/test_acp_entrypoint_smoke.py new file mode 100644 index 0000000..2a496dd --- /dev/null +++ b/tests/acp/test_acp_entrypoint_smoke.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import asyncio +import asyncio.subprocess as aio_subprocess +import contextlib +import io +import os +from pathlib import Path +from typing import Any, cast + +from acp import PROTOCOL_VERSION, Client, RequestError, connect_to_agent +from acp.schema import ClientCapabilities, Implementation +import pexpect +import pytest + +from tests import TESTS_ROOT +from tests.e2e.common import ansi_tolerant_pattern + + +class _AcpSmokeClient(Client): + def on_connect(self, conn: Any) -> None: + pass + + async def request_permission(self, *args: Any, **kwargs: Any) -> Any: + msg = "session/request_permission" + raise RequestError.method_not_found(msg) + + async def write_text_file(self, *args: Any, **kwargs: Any) -> Any: + msg = "fs/write_text_file" + raise RequestError.method_not_found(msg) + + async def read_text_file(self, *args: Any, **kwargs: Any) -> Any: + msg = "fs/read_text_file" + raise RequestError.method_not_found(msg) + + async def create_terminal(self, *args: Any, **kwargs: Any) -> Any: + msg = "terminal/create" + raise RequestError.method_not_found(msg) + + async def terminal_output(self, *args: Any, **kwargs: Any) -> Any: + msg = "terminal/output" + raise RequestError.method_not_found(msg) + + async def release_terminal(self, *args: Any, **kwargs: Any) -> Any: + msg = "terminal/release" + raise RequestError.method_not_found(msg) + + async def wait_for_terminal_exit(self, *args: Any, **kwargs: Any) -> Any: + msg = "terminal/wait_for_exit" + raise RequestError.method_not_found(msg) + + async def kill_terminal(self, *args: Any, **kwargs: Any) -> Any: + msg = "terminal/kill" + raise RequestError.method_not_found(msg) + + async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + _ = params + raise RequestError.method_not_found(method) + + async def ext_notification(self, method: str, params: dict[str, Any]) -> None: + _ = params + raise RequestError.method_not_found(method) + + async def session_update(self, *_args: Any, **_kwargs: Any) -> None: + pass + + +@pytest.fixture +def vibe_home_dir(tmp_path: Path) -> Path: + return tmp_path / ".vibe" + + +async def _spawn_vibe_acp(env: dict[str, str]) -> asyncio.subprocess.Process: + return await asyncio.create_subprocess_exec( + "uv", + "run", + "vibe-acp", + stdin=aio_subprocess.PIPE, + stdout=aio_subprocess.PIPE, + stderr=aio_subprocess.PIPE, + cwd=TESTS_ROOT.parent, + env=env, + ) + + +async def _terminate_process(proc: asyncio.subprocess.Process) -> None: + if proc.returncode is None: + with contextlib.suppress(ProcessLookupError): + proc.terminate() + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(proc.wait(), timeout=5) + + if proc.returncode is None: + with contextlib.suppress(ProcessLookupError): + proc.kill() + await proc.wait() + + +def _build_env(vibe_home_dir: Path, *, include_api_key: bool) -> dict[str, str]: + env = os.environ.copy() + env["PYTHONUNBUFFERED"] = "1" + env["VIBE_HOME"] = str(vibe_home_dir) + + if include_api_key: + env["MISTRAL_API_KEY"] = "mock" + else: + env.pop("MISTRAL_API_KEY", None) + + return env + + +def _build_client_capabilities(*, terminal_auth: bool = False) -> ClientCapabilities: + if not terminal_auth: + return ClientCapabilities() + + return ClientCapabilities(field_meta={"terminal-auth": True}) + + +async def _connect_and_initialize( + *, vibe_home_dir: Path, include_api_key: bool, terminal_auth: bool = False +) -> tuple[asyncio.subprocess.Process, Any, Any]: + env = _build_env(vibe_home_dir, include_api_key=include_api_key) + proc = await _spawn_vibe_acp(env) + + try: + assert proc.stdin is not None + assert proc.stdout is not None + + conn = connect_to_agent(_AcpSmokeClient(), proc.stdin, proc.stdout) + initialize_response = await asyncio.wait_for( + conn.initialize( + protocol_version=PROTOCOL_VERSION, + client_capabilities=_build_client_capabilities( + terminal_auth=terminal_auth + ), + client_info=Implementation( + name="pytest-smoke", title="Pytest Smoke", version="0.0.0" + ), + ), + timeout=10, + ) + except Exception: + await _terminate_process(proc) + raise + + return proc, initialize_response, conn + + +@pytest.mark.asyncio +async def test_vibe_acp_initialize_and_new_session(vibe_home_dir: Path) -> None: + proc, initialize_response, conn = await _connect_and_initialize( + vibe_home_dir=vibe_home_dir, include_api_key=True + ) + + try: + assert initialize_response.protocol_version == PROTOCOL_VERSION + assert initialize_response.agent_info.name == "@mistralai/mistral-vibe" + assert initialize_response.agent_info.title == "Mistral Vibe" + + session = await asyncio.wait_for( + conn.new_session(cwd=str(Path.cwd()), mcp_servers=[]), timeout=10 + ) + + assert session.session_id + finally: + await _terminate_process(proc) + + +@pytest.mark.asyncio +async def test_vibe_acp_bootstraps_default_files(vibe_home_dir: Path) -> None: + proc, _initialize_response, conn = await _connect_and_initialize( + vibe_home_dir=vibe_home_dir, include_api_key=True + ) + + try: + await asyncio.wait_for( + conn.new_session(cwd=str(Path.cwd()), mcp_servers=[]), timeout=10 + ) + finally: + await _terminate_process(proc) + assert (vibe_home_dir / "config.toml").is_file() + assert (vibe_home_dir / "vibehistory").is_file() + + +@pytest.mark.asyncio +async def test_vibe_acp_initialize_exposes_terminal_auth_when_supported( + vibe_home_dir: Path, +) -> None: + proc, initialize_response, conn = await _connect_and_initialize( + vibe_home_dir=vibe_home_dir, include_api_key=True, terminal_auth=True + ) + + try: + assert initialize_response.auth_methods is not None + assert len(initialize_response.auth_methods) == 1 + + auth_method = initialize_response.auth_methods[0] + assert auth_method.id == "vibe-setup" + assert auth_method.field_meta is not None + + terminal_auth = auth_method.field_meta["terminal-auth"] + assert terminal_auth["label"] == "Mistral Vibe Setup" + assert terminal_auth["command"] + assert terminal_auth["args"] + finally: + await _terminate_process(proc) + + +@pytest.mark.timeout(15) +def test_vibe_acp_setup_shows_onboarding_and_exits_on_cancel( + vibe_home_dir: Path, +) -> None: + env = cast("os._Environ[str]", _build_env(vibe_home_dir, include_api_key=False)) + env["TERM"] = "xterm-256color" + + captured = io.StringIO() + child = pexpect.spawn( + "uv", + ["run", "vibe-acp", "--setup"], + cwd=str(TESTS_ROOT.parent), + env=env, + encoding="utf-8", + timeout=10, + dimensions=(36, 120), + ) + child.logfile_read = captured + + try: + child.expect(ansi_tolerant_pattern("Welcome to Mistral Vibe"), timeout=10) + child.sendcontrol("c") + child.expect(pexpect.EOF, timeout=10) + finally: + if child.isalive(): + child.terminate(force=True) + if not child.closed: + child.close() + + output = captured.getvalue() + assert "Setup cancelled" in output + + +@pytest.mark.asyncio +async def test_vibe_acp_new_session_fails_without_api_key(vibe_home_dir: Path) -> None: + proc, _initialize_response, conn = await _connect_and_initialize( + vibe_home_dir=vibe_home_dir, include_api_key=False + ) + + try: + with pytest.raises(RequestError, match="Missing API key"): + await asyncio.wait_for( + conn.new_session(cwd=str(Path.cwd()), mcp_servers=[]), timeout=10 + ) + finally: + await _terminate_process(proc) diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index b8ce362..81bfb30 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -28,7 +28,7 @@ class TestACPInitialize: session_capabilities=SessionCapabilities(list=SessionListCapabilities()), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.5.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.6.0" ) assert response.auth_methods == [] @@ -52,7 +52,7 @@ class TestACPInitialize: session_capabilities=SessionCapabilities(list=SessionListCapabilities()), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.5.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.6.0" ) assert response.auth_methods is not None diff --git a/tests/acp/test_load_session.py b/tests/acp/test_load_session.py index 302b65c..5f7b0f7 100644 --- a/tests/acp/test_load_session.py +++ b/tests/acp/test_load_session.py @@ -100,13 +100,11 @@ class TestLoadSession: assert response.config_options is not None assert len(response.config_options) == 2 - assert response.config_options[0].root.id == "mode" - assert response.config_options[0].root.category == "mode" - assert response.config_options[0].root.current_value == BuiltinAgentName.DEFAULT - assert len(response.config_options[0].root.options) == 5 - mode_option_values = { - opt.value for opt in response.config_options[0].root.options - } + assert response.config_options[0].id == "mode" + assert response.config_options[0].category == "mode" + assert response.config_options[0].current_value == BuiltinAgentName.DEFAULT + assert len(response.config_options[0].options) == 5 + mode_option_values = {opt.value for opt in response.config_options[0].options} assert mode_option_values == { BuiltinAgentName.DEFAULT, BuiltinAgentName.CHAT, @@ -114,13 +112,11 @@ class TestLoadSession: BuiltinAgentName.PLAN, BuiltinAgentName.ACCEPT_EDITS, } - assert response.config_options[1].root.id == "model" - assert response.config_options[1].root.category == "model" - assert response.config_options[1].root.current_value == "devstral-latest" - assert len(response.config_options[1].root.options) == 2 - model_option_values = { - opt.value for opt in response.config_options[1].root.options - } + assert response.config_options[1].id == "model" + assert response.config_options[1].category == "model" + assert response.config_options[1].current_value == "devstral-latest" + assert len(response.config_options[1].options) == 2 + model_option_values = {opt.value for opt in response.config_options[1].options} assert model_option_values == {"devstral-latest", "devstral-small"} @pytest.mark.asyncio diff --git a/tests/acp/test_new_session.py b/tests/acp/test_new_session.py index 52942d8..c57f09e 100644 --- a/tests/acp/test_new_session.py +++ b/tests/acp/test_new_session.py @@ -103,11 +103,11 @@ class TestACPNewSession: # Mode config option mode_config = session_response.config_options[0] - assert mode_config.root.id == "mode" - assert mode_config.root.category == "mode" - assert mode_config.root.current_value == BuiltinAgentName.DEFAULT - assert len(mode_config.root.options) == 5 - mode_option_values = {opt.value for opt in mode_config.root.options} + assert mode_config.id == "mode" + assert mode_config.category == "mode" + assert mode_config.current_value == BuiltinAgentName.DEFAULT + assert len(mode_config.options) == 5 + mode_option_values = {opt.value for opt in mode_config.options} assert mode_option_values == { BuiltinAgentName.DEFAULT, BuiltinAgentName.CHAT, @@ -118,11 +118,11 @@ class TestACPNewSession: # Model config option model_config = session_response.config_options[1] - assert model_config.root.id == "model" - assert model_config.root.category == "model" - assert model_config.root.current_value == "devstral-latest" - assert len(model_config.root.options) == 2 - model_option_values = {opt.value for opt in model_config.root.options} + assert model_config.id == "model" + assert model_config.category == "model" + assert model_config.current_value == "devstral-latest" + assert len(model_config.options) == 2 + model_option_values = {opt.value for opt in model_config.options} assert model_option_values == {"devstral-latest", "devstral-small"} @pytest.mark.skip(reason="TODO: Fix this test") diff --git a/tests/acp/test_set_config_option.py b/tests/acp/test_set_config_option.py index 92bb7b1..5f3866f 100644 --- a/tests/acp/test_set_config_option.py +++ b/tests/acp/test_set_config_option.py @@ -83,8 +83,8 @@ class TestACPSetConfigOptionMode: # Verify config_options reflect the new state mode_config = response.config_options[0] - assert mode_config.root.id == "mode" - assert mode_config.root.current_value == BuiltinAgentName.AUTO_APPROVE + assert mode_config.id == "mode" + assert mode_config.current_value == BuiltinAgentName.AUTO_APPROVE @pytest.mark.asyncio async def test_set_config_option_mode_to_plan( @@ -133,8 +133,8 @@ class TestACPSetConfigOptionMode: ) # Chat mode auto-approves read-only tools mode_config = response.config_options[0] - assert mode_config.root.id == "mode" - assert mode_config.root.current_value == BuiltinAgentName.CHAT + assert mode_config.id == "mode" + assert mode_config.current_value == BuiltinAgentName.CHAT @pytest.mark.asyncio async def test_set_config_option_mode_invalid_returns_none( @@ -205,8 +205,8 @@ class TestACPSetConfigOptionModel: # Verify config_options reflect the new state model_config = response.config_options[1] - assert model_config.root.id == "model" - assert model_config.root.current_value == "devstral-small" + assert model_config.id == "model" + assert model_config.current_value == "devstral-small" @pytest.mark.asyncio async def test_set_config_option_model_invalid_returns_none( diff --git a/tests/acp/test_usage_update.py b/tests/acp/test_usage_update.py new file mode 100644 index 0000000..856f5a5 --- /dev/null +++ b/tests/acp/test_usage_update.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from unittest.mock import patch + +from acp.schema import TextContentBlock, UsageUpdate +import pytest + +from tests.acp.conftest import _create_acp_agent +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.config import SessionLoggingConfig +from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role + + +def _make_backend(prompt_tokens: int = 100, completion_tokens: int = 50) -> FakeBackend: + return FakeBackend( + LLMChunk( + message=LLMMessage(role=Role.assistant, content="Hi"), + usage=LLMUsage( + prompt_tokens=prompt_tokens, completion_tokens=completion_tokens + ), + ) + ) + + +def _make_acp_agent(backend: FakeBackend) -> 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(agent: VibeAcpAgentLoop) -> FakeClient: + return agent.client # type: ignore[return-value] + + +def _get_usage_updates(client: FakeClient) -> list[UsageUpdate]: + return [ + update.update + for update in client._session_updates + if isinstance(update.update, UsageUpdate) + ] + + +class TestPromptResponseUsage: + @pytest.mark.asyncio + async def test_prompt_returns_usage_in_response(self) -> None: + agent = _make_acp_agent(_make_backend(prompt_tokens=100, completion_tokens=50)) + session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + response = await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello")], + ) + + assert response.usage is not None + assert response.usage.input_tokens == 100 + assert response.usage.output_tokens == 50 + assert response.usage.total_tokens == 150 + + @pytest.mark.asyncio + async def test_prompt_usage_optional_fields_are_none(self) -> None: + agent = _make_acp_agent(_make_backend()) + session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + response = await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello")], + ) + + assert response.usage is not None + assert response.usage.thought_tokens is None + assert response.usage.cached_read_tokens is None + assert response.usage.cached_write_tokens is None + + @pytest.mark.asyncio + async def test_prompt_usage_accumulates_across_turns(self) -> None: + backend = _make_backend(prompt_tokens=100, completion_tokens=50) + agent = _make_acp_agent(backend) + session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + first = await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello")], + ) + + second = await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello again")], + ) + + assert first.usage is not None + assert second.usage is not None + # Second turn should have strictly more cumulative tokens + assert second.usage.input_tokens > first.usage.input_tokens + assert second.usage.output_tokens > first.usage.output_tokens + assert second.usage.total_tokens > first.usage.total_tokens + + +class TestUsageUpdateNotification: + @pytest.mark.asyncio + async def test_prompt_sends_usage_update(self) -> None: + agent = _make_acp_agent(_make_backend()) + session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello")], + ) + await asyncio.sleep(0) + + usage_updates = _get_usage_updates(_get_fake_client(agent)) + assert len(usage_updates) == 1 + assert usage_updates[0].session_update == "usage_update" + + @pytest.mark.asyncio + async def test_usage_update_contains_context_window_info(self) -> None: + agent = _make_acp_agent(_make_backend(prompt_tokens=100, completion_tokens=50)) + session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello")], + ) + await asyncio.sleep(0) + + usage_updates = _get_usage_updates(_get_fake_client(agent)) + assert len(usage_updates) == 1 + assert usage_updates[0].size > 0 + assert usage_updates[0].used > 0 + + @pytest.mark.asyncio + async def test_usage_update_contains_cost_when_pricing_set(self) -> None: + agent = _make_acp_agent( + _make_backend(prompt_tokens=1_000_000, completion_tokens=500_000) + ) + session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + # Set pricing directly on the session stats (config loading uses fixture defaults) + acp_session = agent.sessions[session.session_id] + acp_session.agent_loop.stats.update_pricing(input_price=0.4, output_price=2.0) + + await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello")], + ) + await asyncio.sleep(0) + + usage_updates = _get_usage_updates(_get_fake_client(agent)) + assert len(usage_updates) == 1 + cost = usage_updates[0].cost + assert cost is not None + assert cost.currency == "USD" + assert cost.amount > 0 + + @pytest.mark.asyncio + async def test_usage_update_no_cost_when_zero_pricing(self) -> None: + agent = _make_acp_agent(_make_backend()) + session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello")], + ) + await asyncio.sleep(0) + + usage_updates = _get_usage_updates(_get_fake_client(agent)) + assert len(usage_updates) == 1 + assert usage_updates[0].cost is None + + @pytest.mark.asyncio + async def test_usage_update_sent_per_prompt(self) -> None: + backend = _make_backend() + agent = _make_acp_agent(backend) + session = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello")], + ) + await asyncio.sleep(0) + await agent.prompt( + session_id=session.session_id, + prompt=[TextContentBlock(type="text", text="Hello again")], + ) + await asyncio.sleep(0) + + usage_updates = _get_usage_updates(_get_fake_client(agent)) + assert len(usage_updates) == 2 + + +class TestLoadSessionUsageUpdate: + def _make_session_dir(self, tmp_path: Path, session_id: str, cwd: str) -> Path: + session_folder = tmp_path / f"session_20240101_120000_{session_id[:8]}" + session_folder.mkdir() + messages_file = session_folder / "messages.jsonl" + with messages_file.open("w") as f: + f.write(json.dumps({"role": "user", "content": "Hello"}) + "\n") + meta = { + "session_id": session_id, + "start_time": "2024-01-01T12:00:00Z", + "end_time": "2024-01-01T12:05:00Z", + "environment": {"working_directory": cwd}, + } + with (session_folder / "meta.json").open("w") as f: + json.dump(meta, f) + return session_folder + + def _make_agent_with_session_logging( + self, backend: FakeBackend, session_dir: Path + ) -> VibeAcpAgentLoop: + session_config = SessionLoggingConfig( + save_dir=str(session_dir), session_prefix="session", enabled=True + ) + config = build_test_vibe_config(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() + + patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start() + agent = _create_acp_agent() + patch.object(agent, "_load_config", return_value=config).start() + return agent + + @pytest.mark.asyncio + async def test_load_session_sends_usage_update(self, tmp_path: Path) -> None: + backend = _make_backend() + agent = self._make_agent_with_session_logging(backend, tmp_path) + session_id = "test-session-load-usage" + self._make_session_dir(tmp_path, session_id, str(Path.cwd())) + + await agent.load_session(cwd=str(Path.cwd()), session_id=session_id) + await asyncio.sleep(0) + + client = _get_fake_client(agent) + usage_updates = _get_usage_updates(client) + assert len(usage_updates) == 1 + assert usage_updates[0].session_update == "usage_update" + assert usage_updates[0].size > 0 diff --git a/tests/acp/test_utils.py b/tests/acp/test_utils.py index 361425f..4a938ab 100644 --- a/tests/acp/test_utils.py +++ b/tests/acp/test_utils.py @@ -1,8 +1,14 @@ from __future__ import annotations -from vibe.acp.utils import get_proxy_help_text +from vibe.acp.utils import ( + TOOL_OPTIONS, + ToolOption, + build_permission_options, + get_proxy_help_text, +) from vibe.core.paths import GLOBAL_ENV_FILE from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS +from vibe.core.tools.permissions import PermissionScope, RequiredPermission def _write_env_file(content: str) -> None: @@ -53,3 +59,65 @@ class TestGetProxyHelpText: assert "HTTP_PROXY=http://proxy:8080" in result assert "HTTPS_PROXY=" not in result + + +class TestBuildPermissionOptions: + def test_no_permissions_returns_default_options(self) -> None: + result = build_permission_options(None) + assert result is TOOL_OPTIONS + + def test_empty_list_returns_default_options(self) -> None: + result = build_permission_options([]) + assert result is TOOL_OPTIONS + + def test_with_permissions_includes_labels_in_allow_always(self) -> None: + permissions = [ + RequiredPermission( + scope=PermissionScope.COMMAND_PATTERN, + invocation_pattern="npm install foo", + session_pattern="npm install *", + label="npm install *", + ) + ] + result = build_permission_options(permissions) + + assert len(result) == 3 + allow_always = next(o for o in result if o.option_id == ToolOption.ALLOW_ALWAYS) + assert "npm install *" in allow_always.name + assert "session" in allow_always.name.lower() + + def test_allow_always_has_field_meta(self) -> None: + permissions = [ + RequiredPermission( + scope=PermissionScope.COMMAND_PATTERN, + invocation_pattern="mkdir foo", + session_pattern="mkdir *", + label="mkdir *", + ) + ] + result = build_permission_options(permissions) + + allow_always = next(o for o in result if o.option_id == ToolOption.ALLOW_ALWAYS) + assert allow_always.field_meta is not None + assert "required_permissions" in allow_always.field_meta + meta_perms = allow_always.field_meta["required_permissions"] + assert len(meta_perms) == 1 + assert meta_perms[0]["session_pattern"] == "mkdir *" + + def test_allow_once_and_reject_unchanged(self) -> None: + permissions = [ + RequiredPermission( + scope=PermissionScope.URL_PATTERN, + invocation_pattern="example.com", + session_pattern="example.com", + label="fetching from example.com", + ) + ] + result = build_permission_options(permissions) + + allow_once = next(o for o in result if o.option_id == ToolOption.ALLOW_ONCE) + reject_once = next(o for o in result if o.option_id == ToolOption.REJECT_ONCE) + assert allow_once.name == "Allow once" + assert reject_once.name == "Reject once" + assert allow_once.field_meta is None + assert reject_once.field_meta is None diff --git a/tests/audio_player/test_audio_player.py b/tests/audio_player/test_audio_player.py new file mode 100644 index 0000000..5c78271 --- /dev/null +++ b/tests/audio_player/test_audio_player.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import io +import struct +from unittest.mock import MagicMock, patch +import wave + +import pytest + +try: + import sounddevice as sd +except OSError: + pytest.skip("PortAudio library not available", allow_module_level=True) + +from vibe.core.audio_player.audio_player import AudioPlayer +from vibe.core.audio_player.audio_player_port import ( + AlreadyPlayingError, + AudioBackendUnavailableError, + AudioFormat, + NoAudioOutputDeviceError, + UnsupportedAudioFormatError, +) + + +def _make_wav_bytes( + n_frames: int = 1024, sample_rate: int = 48_000, channels: int = 1 +) -> bytes: + buf = io.BytesIO() + with wave.open(buf, "wb") as wf: + wf.setnchannels(channels) + wf.setsampwidth(2) + wf.setframerate(sample_rate) + wf.writeframes( + struct.pack(f"<{n_frames * channels}h", *([1000] * n_frames * channels)) + ) + return buf.getvalue() + + +def _get_callback(mock_stream_cls: MagicMock): + return mock_stream_cls.call_args.kwargs["callback"] + + +def _get_finished_callback(mock_stream_cls: MagicMock): + return mock_stream_cls.call_args.kwargs["finished_callback"] + + +class TestAudioPlayerInitialState: + def test_not_playing(self) -> None: + player = AudioPlayer() + assert player.is_playing is False + + +class TestPlayback: + @patch("vibe.core.audio_player.audio_player.sd.RawOutputStream") + def test_play_sets_playing_state(self, mock_stream_cls: MagicMock) -> None: + player = AudioPlayer() + player.play(_make_wav_bytes(), AudioFormat.WAV) + assert player.is_playing is True + mock_stream_cls.return_value.start.assert_called_once() + + @patch("vibe.core.audio_player.audio_player.sd.RawOutputStream") + def test_play_when_already_playing_raises(self, mock_stream_cls: MagicMock) -> None: + player = AudioPlayer() + player.play(_make_wav_bytes(), AudioFormat.WAV) + with pytest.raises(AlreadyPlayingError): + player.play(_make_wav_bytes(), AudioFormat.WAV) + + @patch("vibe.core.audio_player.audio_player.sd.RawOutputStream") + def test_callback_feeds_audio_data(self, mock_stream_cls: MagicMock) -> None: + wav_data = _make_wav_bytes(n_frames=512) + player = AudioPlayer() + player.play(wav_data, AudioFormat.WAV) + + callback = _get_callback(mock_stream_cls) + outdata = bytearray(512 * 2) # 512 frames * 2 bytes per sample + callback(outdata, 512, {}, sd.CallbackFlags()) + + assert outdata != bytearray(512 * 2) + + @patch("vibe.core.audio_player.audio_player.sd.RawOutputStream") + def test_callback_pads_silence_at_end(self, mock_stream_cls: MagicMock) -> None: + wav_data = _make_wav_bytes(n_frames=256) + player = AudioPlayer() + player.play(wav_data, AudioFormat.WAV) + + callback = _get_callback(mock_stream_cls) + # First callback consumes all 256 frames + outdata1 = bytearray(256 * 2) + callback(outdata1, 256, {}, sd.CallbackFlags()) + + # Second callback should raise CallbackStop (no data left) + outdata2 = bytearray(256 * 2) + with pytest.raises(sd.CallbackStop): + callback(outdata2, 256, {}, sd.CallbackFlags()) + + @patch("vibe.core.audio_player.audio_player.sd.RawOutputStream") + def test_on_finished_called_after_natural_completion( + self, mock_stream_cls: MagicMock + ) -> None: + finished = [] + player = AudioPlayer() + player.play( + _make_wav_bytes(), + AudioFormat.WAV, + on_finished=lambda: finished.append(True), + ) + + finished_callback = _get_finished_callback(mock_stream_cls) + finished_callback() + + assert player.is_playing is False + assert len(finished) == 1 + + @patch("vibe.core.audio_player.audio_player.sd.RawOutputStream") + def test_can_play_multiple_times(self, mock_stream_cls: MagicMock) -> None: + player = AudioPlayer() + + player.play(_make_wav_bytes(), AudioFormat.WAV) + finished_callback = _get_finished_callback(mock_stream_cls) + finished_callback() + assert player.is_playing is False + + player.play(_make_wav_bytes(), AudioFormat.WAV) + assert player.is_playing is True + + @patch("vibe.core.audio_player.audio_player.sd.RawOutputStream") + def test_creates_stream_with_correct_params( + self, mock_stream_cls: MagicMock + ) -> None: + wav_data = _make_wav_bytes(sample_rate=24_000, channels=1) + player = AudioPlayer() + player.play(wav_data, AudioFormat.WAV) + + call_kwargs = mock_stream_cls.call_args.kwargs + assert call_kwargs["samplerate"] == 24_000 + assert call_kwargs["channels"] == 1 + assert call_kwargs["dtype"] == "int16" + + +class TestStop: + @patch("vibe.core.audio_player.audio_player.sd.RawOutputStream") + def test_stop_closes_stream(self, mock_stream_cls: MagicMock) -> None: + player = AudioPlayer() + player.play(_make_wav_bytes(), AudioFormat.WAV) + player.stop() + mock_stream_cls.return_value.close.assert_called_once() + + @patch("vibe.core.audio_player.audio_player.sd.RawOutputStream") + def test_finished_callback_resets_state(self, mock_stream_cls: MagicMock) -> None: + player = AudioPlayer() + player.play(_make_wav_bytes(), AudioFormat.WAV) + + finished_callback = _get_finished_callback(mock_stream_cls) + finished_callback() + + assert player.is_playing is False + + def test_stop_when_not_playing_is_noop(self) -> None: + player = AudioPlayer() + player.stop() + assert player.is_playing is False + + @patch("vibe.core.audio_player.audio_player.sd.RawOutputStream") + def test_stop_triggers_on_finished_via_callback( + self, mock_stream_cls: MagicMock + ) -> None: + finished = [] + player = AudioPlayer() + player.play( + _make_wav_bytes(), + AudioFormat.WAV, + on_finished=lambda: finished.append(True), + ) + + # Simulate sounddevice calling finished_callback after stop + finished_callback = _get_finished_callback(mock_stream_cls) + finished_callback() + + assert len(finished) == 1 + + +class TestUnsupportedFormat: + def test_unsupported_format_raises(self) -> None: + player = AudioPlayer() + with pytest.raises(UnsupportedAudioFormatError): + player.play(b"fake data", "mp3") # type: ignore[arg-type] + + +class TestGuardAudioOutput: + def test_raises_when_no_sounddevice(self) -> None: + with patch("vibe.core.audio_player.audio_player.sd", None): + player = AudioPlayer() + with pytest.raises(AudioBackendUnavailableError): + player.play(_make_wav_bytes(), AudioFormat.WAV) + + @patch("vibe.core.audio_player.audio_player.sd.RawOutputStream") + def test_raises_when_no_output_device(self, mock_stream_cls: MagicMock) -> None: + with patch( + "vibe.core.audio_player.audio_player.sd.query_devices", + side_effect=sd.PortAudioError(-1), + ): + player = AudioPlayer() + with pytest.raises(NoAudioOutputDeviceError): + player.play(_make_wav_bytes(), AudioFormat.WAV) + assert player.is_playing is False + mock_stream_cls.assert_not_called() diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py index 6961395..dd2c023 100644 --- a/tests/backend/test_backend.py +++ b/tests/backend/test_backend.py @@ -16,6 +16,7 @@ import json from unittest.mock import MagicMock, patch import httpx +from mistralai.client.models import AssistantMessage from mistralai.client.utils.retries import BackoffStrategy, RetryConfig import pytest import respx @@ -33,13 +34,13 @@ from tests.backend.data.mistral import ( STREAMED_TOOL_CONVERSATION_PARAMS as MISTRAL_STREAMED_TOOL_CONVERSATION_PARAMS, TOOL_CONVERSATION_PARAMS as MISTRAL_TOOL_CONVERSATION_PARAMS, ) -from vibe.core.config import Backend, ModelConfig, ProviderConfig +from vibe.core.config import ModelConfig, ProviderConfig from vibe.core.llm.backend.factory import BACKEND_FACTORY from vibe.core.llm.backend.generic import GenericBackend -from vibe.core.llm.backend.mistral import MistralBackend +from vibe.core.llm.backend.mistral import MistralBackend, MistralMapper from vibe.core.llm.exceptions import BackendError from vibe.core.llm.types import BackendLike -from vibe.core.types import LLMChunk, LLMMessage, Role, ToolCall +from vibe.core.types import Backend, FunctionCall, LLMChunk, LLMMessage, Role, ToolCall from vibe.core.utils import get_user_agent @@ -435,3 +436,93 @@ class TestMistralRetry: timeout_ms=720000, retry_config=backend._retry_config, ) + + +class TestMistralMapperPrepareMessage: + """Tests for MistralMapper.prepare_message thinking-block handling. + + The Mistral API returns assistant messages with reasoning as a single + ThinkChunk (no trailing TextChunk when there is no text content). When + the mapper rebuilds the message for the next request it must NOT append + an empty TextChunk, otherwise the proxy's history-consistency check + sees a content mismatch on every turn and creates spurious conversation + segments. + """ + + @pytest.fixture + def mapper(self) -> MistralMapper: + return MistralMapper() + + def test_reasoning_only_no_empty_text_chunk(self, mapper: MistralMapper) -> None: + """Assistant with reasoning_content but no text content should produce + only a ThinkChunk — no trailing empty TextChunk. + """ + msg = LLMMessage( + role=Role.assistant, + content=None, + reasoning_content="Let me think step by step.", + ) + result = mapper.prepare_message(msg) + content = result.content + assert isinstance(content, list) + assert len(content) == 1 + assert content[0].type == "thinking" + + def test_reasoning_with_empty_string_content(self, mapper: MistralMapper) -> None: + """content='' (empty string) should also not produce a trailing TextChunk.""" + msg = LLMMessage( + role=Role.assistant, content="", reasoning_content="Thinking..." + ) + result = mapper.prepare_message(msg) + content = result.content + assert isinstance(content, list) + assert len(content) == 1 + assert content[0].type == "thinking" + + def test_reasoning_with_text_content(self, mapper: MistralMapper) -> None: + """When there is actual text content, both ThinkChunk and TextChunk + should be present. + """ + msg = LLMMessage( + role=Role.assistant, + content="Here is the answer.", + reasoning_content="Let me reason.", + ) + result = mapper.prepare_message(msg) + content = result.content + assert isinstance(content, list) + assert len(content) == 2 + assert content[0].type == "thinking" + assert content[1].type == "text" + assert content[1].text == "Here is the answer." + + def test_reasoning_with_tool_calls_no_text(self, mapper: MistralMapper) -> None: + """Reasoning + tool_calls but no text content — only ThinkChunk.""" + msg = LLMMessage( + role=Role.assistant, + content=None, + reasoning_content="I should run a command.", + tool_calls=[ + ToolCall( + id="tc_1", + index=0, + function=FunctionCall(name="bash", arguments='{"cmd": "ls"}'), + ) + ], + ) + result = mapper.prepare_message(msg) + assert isinstance(result, AssistantMessage) + content = result.content + assert isinstance(content, list) + assert len(content) == 1 + assert content[0].type == "thinking" + # Tool calls should still be present + assert isinstance(result.tool_calls, list) + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].function.name == "bash" + + def test_no_reasoning_plain_string(self, mapper: MistralMapper) -> None: + """Without reasoning_content, content is a plain string.""" + msg = LLMMessage(role=Role.assistant, content="Hello!") + result = mapper.prepare_message(msg) + assert result.content == "Hello!" diff --git a/tests/cli/plan_offer/test_decide_plan_offer.py b/tests/cli/plan_offer/test_decide_plan_offer.py index 1dfc6da..fec6772 100644 --- a/tests/cli/plan_offer/test_decide_plan_offer.py +++ b/tests/cli/plan_offer/test_decide_plan_offer.py @@ -13,7 +13,8 @@ from vibe.cli.plan_offer.decide_plan_offer import ( resolve_api_key_for_plan, ) from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse -from vibe.core.config import Backend, ProviderConfig +from vibe.core.config import ProviderConfig +from vibe.core.types import Backend @pytest.fixture diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 495d5d9..805c8e8 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -8,7 +8,7 @@ class TestCommandRegistry: 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("/model") == "model" assert registry.get_command_name("/clear") == "clear" assert registry.get_command_name("/exit") == "exit" diff --git a/tests/cli/test_feedback_bar.py b/tests/cli/test_feedback_bar.py new file mode 100644 index 0000000..6b3cd27 --- /dev/null +++ b/tests/cli/test_feedback_bar.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar + + +class TestFeedbackBarState: + def test_maybe_show_shows_when_random_below_threshold(self): + bar = FeedbackBar() + bar.display = False + bar._set_active = MagicMock() + + with patch( + "vibe.cli.textual_ui.widgets.feedback_bar.random.random", return_value=0 + ): + bar.maybe_show() + + bar._set_active.assert_called_once_with(True) + + def test_maybe_show_does_not_show_when_random_above_threshold(self): + bar = FeedbackBar() + bar.display = False + bar._set_active = MagicMock() + + with patch( + "vibe.cli.textual_ui.widgets.feedback_bar.random.random", return_value=1.0 + ): + bar.maybe_show() + + bar._set_active.assert_not_called() + + def test_hide_calls_set_active_false_when_displayed(self): + bar = FeedbackBar() + bar.display = True + bar._set_active = MagicMock() + + bar.hide() + + bar._set_active.assert_called_once_with(False) + + def test_hide_does_nothing_when_already_hidden(self): + bar = FeedbackBar() + bar.display = False + bar._set_active = MagicMock() + + bar.hide() + + bar._set_active.assert_not_called() + + def test_handle_feedback_key_posts_message_and_deactivates(self): + bar = FeedbackBar() + bar.set_timer = MagicMock() + bar.post_message = MagicMock() + bar.query_one = MagicMock() + mock_text_area = MagicMock() + mock_text_area.feedback_active = True + mock_app = MagicMock() + mock_app.query_one.return_value = mock_text_area + + with patch.object( + type(bar), "app", new_callable=lambda: property(lambda self: mock_app) + ): + bar.handle_feedback_key(3) + + assert mock_text_area.feedback_active is False + bar.post_message.assert_called_once() + msg = bar.post_message.call_args[0][0] + assert isinstance(msg, FeedbackBar.FeedbackGiven) + assert msg.rating == 3 + bar.set_timer.assert_called_once() + + +class TestFeedbackGivenMessage: + def test_message_stores_rating(self): + msg = FeedbackBar.FeedbackGiven(rating=2) + assert msg.rating == 2 diff --git a/tests/cli/test_ui_config_and_model_picker.py b/tests/cli/test_ui_config_and_model_picker.py new file mode 100644 index 0000000..dd5d347 --- /dev/null +++ b/tests/cli/test_ui_config_and_model_picker.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from tests.conftest import build_test_vibe_app, build_test_vibe_config +from vibe.cli.textual_ui.app import BottomApp +from vibe.cli.textual_ui.widgets.config_app import ConfigApp +from vibe.cli.textual_ui.widgets.model_picker import ModelPickerApp +from vibe.core.config._settings import ModelConfig + + +def _make_config_with_models(): + models = [ + ModelConfig(name="model-a", provider="mistral", alias="alpha"), + ModelConfig(name="model-b", provider="mistral", alias="beta"), + ModelConfig(name="model-c", provider="mistral", alias="gamma"), + ] + return build_test_vibe_config(models=models, active_model="alpha") + + +# --- /config command --- + + +@pytest.mark.asyncio +async def test_config_opens_config_app() -> None: + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_config() + await pilot.pause(0.2) + + assert app._current_bottom_app == BottomApp.Config + assert len(app.query(ConfigApp)) == 1 + + +@pytest.mark.asyncio +async def test_config_escape_returns_to_input() -> None: + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_config() + await pilot.pause(0.2) + + await pilot.press("escape") + await pilot.pause(0.2) + + assert app._current_bottom_app == BottomApp.Input + assert len(app.query(ConfigApp)) == 0 + + +@pytest.mark.asyncio +async def test_config_toggle_autocopy() -> None: + config = _make_config_with_models() + config.autocopy_to_clipboard = False + app = build_test_vibe_app(config=config) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_config() + await pilot.pause(0.2) + + # Navigate down to Auto-copy (second item) and toggle + await pilot.press("down") + await pilot.press("enter") + await pilot.pause(0.1) + + # Verify the toggle happened in the widget + config_app = app.query_one(ConfigApp) + assert config_app.changes.get("autocopy_to_clipboard") == "On" + + +@pytest.mark.asyncio +async def test_config_escape_saves_changes() -> None: + config = _make_config_with_models() + config.autocopy_to_clipboard = False + app = build_test_vibe_app(config=config) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_config() + await pilot.pause(0.2) + + # Toggle auto-copy + await pilot.press("down") + await pilot.press("enter") + await pilot.pause(0.1) + + with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save: + await pilot.press("escape") + await pilot.pause(0.2) + + mock_save.assert_called_once() + changes = mock_save.call_args[0][0] + assert changes["autocopy_to_clipboard"] is True + + +# --- /model command --- + + +@pytest.mark.asyncio +async def test_model_opens_model_picker() -> None: + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_model() + await pilot.pause(0.2) + + assert app._current_bottom_app == BottomApp.ModelPicker + assert len(app.query(ModelPickerApp)) == 1 + + +@pytest.mark.asyncio +async def test_model_picker_shows_all_models() -> None: + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_model() + await pilot.pause(0.2) + + picker = app.query_one(ModelPickerApp) + assert picker._model_aliases == ["alpha", "beta", "gamma"] + assert picker._current_model == "alpha" + + +@pytest.mark.asyncio +async def test_model_picker_escape_returns_to_input() -> None: + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_model() + await pilot.pause(0.2) + + await pilot.press("escape") + await pilot.pause(0.2) + + assert app._current_bottom_app == BottomApp.Input + assert len(app.query(ModelPickerApp)) == 0 + + +@pytest.mark.asyncio +async def test_model_picker_escape_does_not_save() -> None: + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_model() + await pilot.pause(0.2) + + with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save: + await pilot.press("escape") + await pilot.pause(0.2) + + mock_save.assert_not_called() + + +@pytest.mark.asyncio +async def test_model_picker_select_model() -> None: + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_model() + await pilot.pause(0.2) + + # Navigate down to "beta" and select + await pilot.press("down") + with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save: + await pilot.press("enter") + await pilot.pause(0.2) + + mock_save.assert_called_once_with({"active_model": "beta"}) + + assert app._current_bottom_app == BottomApp.Input + assert len(app.query(ModelPickerApp)) == 0 + + +@pytest.mark.asyncio +async def test_model_picker_select_current_model() -> None: + """Selecting the already-active model still saves (idempotent).""" + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_model() + await pilot.pause(0.2) + + with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save: + await pilot.press("enter") + await pilot.pause(0.2) + + mock_save.assert_called_once_with({"active_model": "alpha"}) + + assert app._current_bottom_app == BottomApp.Input + + +# --- config -> model picker flow --- + + +@pytest.mark.asyncio +async def test_config_model_entry_opens_model_picker() -> None: + """Pressing Enter on the Model row in /config opens the model picker.""" + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_config() + await pilot.pause(0.2) + + # Model row is the first item, already highlighted. Press enter. + await pilot.press("enter") + await pilot.pause(0.3) + + assert app._current_bottom_app == BottomApp.ModelPicker + assert len(app.query(ModelPickerApp)) == 1 + assert len(app.query(ConfigApp)) == 0 + + +@pytest.mark.asyncio +async def test_config_to_model_picker_escape_returns_to_input() -> None: + """Opening model picker from config, then ESC, returns to input (not config).""" + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_config() + await pilot.pause(0.2) + + # Open model picker from config + await pilot.press("enter") + await pilot.pause(0.3) + + # Escape model picker + await pilot.press("escape") + await pilot.pause(0.2) + + assert app._current_bottom_app == BottomApp.Input + assert len(app.query(ModelPickerApp)) == 0 + assert len(app.query(ConfigApp)) == 0 + + +@pytest.mark.asyncio +async def test_config_to_model_picker_select_returns_to_input() -> None: + """Opening model picker from config, selecting a model, returns to input.""" + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_config() + await pilot.pause(0.2) + + # Open model picker from config + await pilot.press("enter") + await pilot.pause(0.3) + + # Select second model + await pilot.press("down") + with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save: + await pilot.press("enter") + await pilot.pause(0.2) + + mock_save.assert_called_once_with({"active_model": "beta"}) + + assert app._current_bottom_app == BottomApp.Input + + +@pytest.mark.asyncio +async def test_config_pending_changes_saved_before_model_picker() -> None: + """Toggle changes in config are saved before switching to model picker.""" + config = _make_config_with_models() + config.autocopy_to_clipboard = False + app = build_test_vibe_app(config=config) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_config() + await pilot.pause(0.2) + + # Toggle auto-copy (second row) + await pilot.press("down") + await pilot.press("enter") + await pilot.pause(0.1) + + # Go back up to model row and open model picker + await pilot.press("up") + with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save: + await pilot.press("enter") + await pilot.pause(0.3) + + mock_save.assert_called_once() + changes = mock_save.call_args[0][0] + assert changes["autocopy_to_clipboard"] is True diff --git a/tests/cli/test_ui_session_exit.py b/tests/cli/test_ui_session_exit.py new file mode 100644 index 0000000..85a0f66 --- /dev/null +++ b/tests/cli/test_ui_session_exit.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import pytest + +from vibe.cli.textual_ui.session_exit import print_session_resume_message +from vibe.core.types import AgentStats + + +def test_print_session_resume_message_skips_output_without_session_id( + capsys: pytest.CaptureFixture[str], +) -> None: + print_session_resume_message(None, AgentStats()) + + assert capsys.readouterr().out == "" + + +def test_print_session_resume_message_prints_resume_commands_and_usage( + capsys: pytest.CaptureFixture[str], +) -> None: + print_session_resume_message( + "12345678-1234-1234-1234-123456789abc", + AgentStats(session_prompt_tokens=14_867, session_completion_tokens=6), + ) + + assert capsys.readouterr().out == ( + "\n" + "Total tokens used this session: input=14,867 output=6 (total=14,873)\n" + "\n" + "To continue this session, run: vibe --continue\n" + "Or: vibe --resume 12345678-1234-1234-1234-123456789abc\n" + ) + + +def test_print_session_resume_message_prints_zero_usage_for_resumed_run_without_llm_activity( + capsys: pytest.CaptureFixture[str], +) -> None: + print_session_resume_message("12345678", AgentStats()) + + assert capsys.readouterr().out == ( + "\n" + "Total tokens used this session: input=0 output=0 (total=0)\n" + "\n" + "To continue this session, run: vibe --continue\n" + "Or: vibe --resume 12345678\n" + ) diff --git a/tests/conftest.py b/tests/conftest.py index 8199819..909c968 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,7 +15,7 @@ from tests.update_notifier.adapters.fake_update_cache_repository import ( ) from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse -from vibe.cli.textual_ui.app import CORE_VERSION, VibeApp +from vibe.cli.textual_ui.app import CORE_VERSION, StartupOptions, VibeApp from vibe.core.agent_loop import AgentLoop from vibe.core.agents.models import BuiltinAgentName from vibe.core.config import ( @@ -121,14 +121,28 @@ def _mock_update_commands(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["true"]) +@pytest.fixture(autouse=True) +def _disable_feedback_bar(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "vibe.cli.textual_ui.widgets.feedback_bar.FEEDBACK_PROBABILITY", 0 + ) + + @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] + self: Any, + event_name: str, + properties: dict[str, Any], + *, + correlation_id: str | None = None, ) -> None: - events.append({"event_name": event_name, "properties": properties}) + event: dict[str, Any] = {"event_name": event_name, "properties": properties} + if correlation_id is not None: + event["correlation_id"] = correlation_id + events.append(event) monkeypatch.setattr( "vibe.core.telemetry.send.TelemetryClient.send_telemetry_event", @@ -236,11 +250,11 @@ def build_test_vibe_app( return VibeApp( agent_loop=resolved_agent_loop, + startup=StartupOptions(initial_prompt=kwargs.pop("initial_prompt", None)), current_version=resolved_current_version, update_notifier=resolved_update_notifier, update_cache_repository=resolved_update_cache_repository, plan_offer_gateway=resolved_plan_offer_gateway, - initial_prompt=kwargs.pop("initial_prompt", None), voice_manager=voice_manager, **kwargs, ) diff --git a/tests/core/test_config_otel.py b/tests/core/test_config_otel.py new file mode 100644 index 0000000..08604be --- /dev/null +++ b/tests/core/test_config_otel.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import pytest + +from vibe.core.config import OtelExporterConfig, ProviderConfig, VibeConfig +from vibe.core.types import Backend + + +class TestOtelExporterConfig: + def test_derives_endpoint_from_mistral_provider( + self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("MISTRAL_API_KEY", "sk-test") + config = vibe_config.model_copy( + update={ + "providers": [ + ProviderConfig( + name="mistral", + api_base="https://customer.mistral.ai/v1", + backend=Backend.MISTRAL, + ) + ] + } + ) + result = config.otel_exporter_config + assert result is not None + assert result.endpoint == "https://customer.mistral.ai/telemetry/v1/traces" + assert result.headers == {"Authorization": "Bearer sk-test"} + + def test_uses_first_mistral_provider( + self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("EU_KEY", "sk-eu") + config = vibe_config.model_copy( + update={ + "providers": [ + ProviderConfig( + name="mistral-eu", + api_base="https://eu.mistral.ai/v1", + api_key_env_var="EU_KEY", + backend=Backend.MISTRAL, + ), + ProviderConfig( + name="mistral-us", + api_base="https://us.mistral.ai/v1", + api_key_env_var="US_KEY", + backend=Backend.MISTRAL, + ), + ] + } + ) + result = config.otel_exporter_config + assert result is not None + assert result.endpoint == "https://eu.mistral.ai/telemetry/v1/traces" + assert result.headers == {"Authorization": "Bearer sk-eu"} + + def test_falls_back_to_default_when_no_mistral_provider( + self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("MISTRAL_API_KEY", "sk-fallback") + config = vibe_config.model_copy( + update={ + "providers": [ + ProviderConfig( + name="anthropic", api_base="https://api.anthropic.com/v1" + ) + ] + } + ) + result = config.otel_exporter_config + assert result is not None + assert result.endpoint == "https://api.mistral.ai/telemetry/v1/traces" + assert result.headers == {"Authorization": "Bearer sk-fallback"} + + def test_default_providers( + self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("MISTRAL_API_KEY", "sk-default") + result = vibe_config.otel_exporter_config + assert result is not None + assert result.endpoint == "https://api.mistral.ai/telemetry/v1/traces" + + def test_returns_none_and_warns_when_api_key_missing( + self, + vibe_config: VibeConfig, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + ) -> None: + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + with caplog.at_level("WARNING"): + assert vibe_config.otel_exporter_config is None + assert "OTEL tracing enabled but MISTRAL_API_KEY is not set" in caplog.text + + def test_custom_api_key_env_var( + self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + monkeypatch.setenv("MY_CUSTOM_KEY", "sk-custom") + config = vibe_config.model_copy( + update={ + "providers": [ + ProviderConfig( + name="mistral-onprem", + api_base="https://onprem.corp.com/v1", + api_key_env_var="MY_CUSTOM_KEY", + backend=Backend.MISTRAL, + ) + ] + } + ) + result = config.otel_exporter_config + assert result is not None + assert result.endpoint == "https://onprem.corp.com/telemetry/v1/traces" + assert result.headers == {"Authorization": "Bearer sk-custom"} + + def test_explicit_otel_endpoint_bypasses_provider_derivation( + self, vibe_config: VibeConfig + ) -> None: + config = vibe_config.model_copy( + update={"otel_endpoint": "https://my-collector:4318/v1/traces"} + ) + result = config.otel_exporter_config + assert result is not None + assert result == OtelExporterConfig( + endpoint="https://my-collector:4318/v1/traces" + ) + assert result.headers is None diff --git a/tests/core/test_local_config_walk.py b/tests/core/test_local_config_walk.py new file mode 100644 index 0000000..5560e90 --- /dev/null +++ b/tests/core/test_local_config_walk.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from pathlib import Path + +from vibe.core.paths._local_config_walk import ( + _MAX_DIRS, + WALK_MAX_DEPTH, + has_config_dirs_nearby, + walk_local_config_dirs_all, +) + + +class TestBoundedWalk: + def test_finds_config_at_root(self, tmp_path: Path) -> None: + (tmp_path / ".vibe" / "tools").mkdir(parents=True) + tools, skills, agents = walk_local_config_dirs_all(tmp_path) + assert tmp_path / ".vibe" / "tools" in tools + + def test_finds_config_within_depth_limit(self, tmp_path: Path) -> None: + nested = tmp_path + for i in range(WALK_MAX_DEPTH): + nested = nested / f"level{i}" + (nested / ".vibe" / "skills").mkdir(parents=True) + _, skills, _ = walk_local_config_dirs_all(tmp_path) + assert nested / ".vibe" / "skills" in skills + + def test_does_not_find_config_beyond_depth_limit(self, tmp_path: Path) -> None: + nested = tmp_path + for i in range(WALK_MAX_DEPTH + 1): + nested = nested / f"level{i}" + (nested / ".vibe" / "tools").mkdir(parents=True) + tools, skills, agents = walk_local_config_dirs_all(tmp_path) + assert not tools + assert not skills + assert not agents + + def test_respects_dir_count_limit(self, tmp_path: Path) -> None: + # Create more directories than _MAX_DIRS at depth 1 + for i in range(_MAX_DIRS + 10): + (tmp_path / f"dir{i:05d}").mkdir() + # Place config in a directory that would be scanned late + (tmp_path / "zzz_last" / ".vibe" / "tools").mkdir(parents=True) + + tools, _, _ = walk_local_config_dirs_all(tmp_path) + # The walk should stop before visiting all dirs. + # Whether zzz_last is found depends on sort order and limit, + # but total visited dirs should be bounded. + # We just verify no crash and the function returns. + assert isinstance(tools, tuple) + + def test_skips_ignored_directories(self, tmp_path: Path) -> None: + (tmp_path / "node_modules" / ".vibe" / "tools").mkdir(parents=True) + (tmp_path / ".vibe" / "tools").mkdir(parents=True) + tools, _, _ = walk_local_config_dirs_all(tmp_path) + assert tools == (tmp_path / ".vibe" / "tools",) + + def test_skips_dot_directories(self, tmp_path: Path) -> None: + (tmp_path / ".hidden" / ".vibe" / "tools").mkdir(parents=True) + tools, _, _ = walk_local_config_dirs_all(tmp_path) + assert not tools + + def test_preserves_alphabetical_ordering(self, tmp_path: Path) -> None: + (tmp_path / "bbb" / ".vibe" / "tools").mkdir(parents=True) + (tmp_path / "aaa" / ".vibe" / "tools").mkdir(parents=True) + (tmp_path / ".vibe" / "tools").mkdir(parents=True) + tools, _, _ = walk_local_config_dirs_all(tmp_path) + assert tools == ( + tmp_path / ".vibe" / "tools", + tmp_path / "aaa" / ".vibe" / "tools", + tmp_path / "bbb" / ".vibe" / "tools", + ) + + def test_finds_agents_skills(self, tmp_path: Path) -> None: + (tmp_path / ".agents" / "skills").mkdir(parents=True) + _, skills, _ = walk_local_config_dirs_all(tmp_path) + assert tmp_path / ".agents" / "skills" in skills + + def test_finds_all_config_types(self, tmp_path: Path) -> None: + (tmp_path / ".vibe" / "tools").mkdir(parents=True) + (tmp_path / ".vibe" / "skills").mkdir(parents=True) + (tmp_path / ".vibe" / "agents").mkdir(parents=True) + (tmp_path / ".agents" / "skills").mkdir(parents=True) + tools, skills, agents = walk_local_config_dirs_all(tmp_path) + assert tmp_path / ".vibe" / "tools" in tools + assert tmp_path / ".vibe" / "skills" in skills + assert tmp_path / ".vibe" / "agents" in agents + assert tmp_path / ".agents" / "skills" in skills + + +class TestHasConfigDirsNearby: + def test_returns_true_when_vibe_tools_exist(self, tmp_path: Path) -> None: + (tmp_path / ".vibe" / "tools").mkdir(parents=True) + assert has_config_dirs_nearby(tmp_path) is True + + def test_returns_true_when_vibe_skills_exist(self, tmp_path: Path) -> None: + (tmp_path / ".vibe" / "skills").mkdir(parents=True) + assert has_config_dirs_nearby(tmp_path) is True + + def test_returns_true_when_agents_skills_exist(self, tmp_path: Path) -> None: + (tmp_path / ".agents" / "skills").mkdir(parents=True) + assert has_config_dirs_nearby(tmp_path) is True + + def test_returns_false_when_empty(self, tmp_path: Path) -> None: + assert has_config_dirs_nearby(tmp_path) is False + + def test_returns_false_for_vibe_dir_without_subdirs(self, tmp_path: Path) -> None: + (tmp_path / ".vibe").mkdir() + assert has_config_dirs_nearby(tmp_path) is False + + def test_returns_true_for_shallow_nested(self, tmp_path: Path) -> None: + (tmp_path / "sub" / ".vibe" / "skills").mkdir(parents=True) + assert has_config_dirs_nearby(tmp_path) is True + + def test_returns_true_at_depth_2(self, tmp_path: Path) -> None: + (tmp_path / "a" / "b" / ".agents" / "skills").mkdir(parents=True) + assert has_config_dirs_nearby(tmp_path) is True + + def test_returns_false_beyond_default_depth(self, tmp_path: Path) -> None: + (tmp_path / "a" / "b" / "c" / "d" / "e" / ".vibe" / "tools").mkdir(parents=True) + assert has_config_dirs_nearby(tmp_path) is False + + def test_custom_depth(self, tmp_path: Path) -> None: + (tmp_path / "a" / "b" / "c" / "d" / "e" / ".vibe" / "tools").mkdir(parents=True) + assert has_config_dirs_nearby(tmp_path, max_depth=5) is True + + def test_early_exit_on_first_match(self, tmp_path: Path) -> None: + # Create many dirs but put config early; function should return quickly + (tmp_path / ".vibe" / "tools").mkdir(parents=True) + for i in range(100): + (tmp_path / f"dir{i}").mkdir() + assert has_config_dirs_nearby(tmp_path) is True + + def test_skips_ignored_directories(self, tmp_path: Path) -> None: + (tmp_path / "node_modules" / ".vibe" / "skills").mkdir(parents=True) + assert has_config_dirs_nearby(tmp_path) is False diff --git a/tests/core/test_slug.py b/tests/core/test_slug.py index 5ddad38..fda4e3c 100644 --- a/tests/core/test_slug.py +++ b/tests/core/test_slug.py @@ -1,6 +1,6 @@ from __future__ import annotations -from vibe.core.slug import _ADJECTIVES, _NOUNS, create_slug +from vibe.core.utils.slug import _ADJECTIVES, _NOUNS, create_slug class TestCreateSlug: diff --git a/tests/core/test_telemetry_send.py b/tests/core/test_telemetry_send.py index e2e0cc6..1d07501 100644 --- a/tests/core/test_telemetry_send.py +++ b/tests/core/test_telemetry_send.py @@ -9,10 +9,10 @@ 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.types import Backend from vibe.core.utils import get_user_agent _original_send_telemetry_event = TelemetryClient.send_telemetry_event @@ -258,6 +258,8 @@ class TestTelemetryClient: nb_mcp_servers=1, nb_models=3, entrypoint="cli", + client_name="vscode", + client_version="1.96.0", terminal_emulator="vscode", ) @@ -270,6 +272,8 @@ class TestTelemetryClient: assert properties["nb_mcp_servers"] == 1 assert properties["nb_models"] == 3 assert properties["entrypoint"] == "cli" + assert properties["client_name"] == "vscode" + assert properties["client_version"] == "1.96.0" assert properties["terminal_emulator"] == "vscode" assert "version" in properties @@ -372,3 +376,41 @@ class TestTelemetryClient: assert ( calls[1].kwargs["json"]["properties"]["session_id"] == "second-session-id" ) + + def test_send_user_rating_feedback_payload( + self, telemetry_events: list[dict[str, Any]] + ) -> None: + config = build_test_vibe_config(enable_telemetry=True) + client = TelemetryClient(config_getter=lambda: config) + + client.send_user_rating_feedback(rating=2, model="mistral-large") + + assert len(telemetry_events) == 1 + assert telemetry_events[0]["event_name"] == "vibe.user_rating_feedback" + properties = telemetry_events[0]["properties"] + assert properties["rating"] == 2 + assert properties["model"] == "mistral-large" + assert "version" in properties + + def test_send_user_rating_feedback_includes_correlation_id( + self, telemetry_events: list[dict[str, Any]] + ) -> None: + config = build_test_vibe_config(enable_telemetry=True) + client = TelemetryClient(config_getter=lambda: config) + client.last_correlation_id = "corr-abc-123" + + client.send_user_rating_feedback(rating=1, model="mistral-large") + + assert len(telemetry_events) == 1 + assert telemetry_events[0]["correlation_id"] == "corr-abc-123" + + def test_send_user_rating_feedback_omits_correlation_id_when_none( + self, telemetry_events: list[dict[str, Any]] + ) -> None: + config = build_test_vibe_config(enable_telemetry=True) + client = TelemetryClient(config_getter=lambda: config) + + client.send_user_rating_feedback(rating=1, model="mistral-large") + + assert len(telemetry_events) == 1 + assert "correlation_id" not in telemetry_events[0] diff --git a/tests/core/test_tts_config.py b/tests/core/test_tts_config.py new file mode 100644 index 0000000..05924bc --- /dev/null +++ b/tests/core/test_tts_config.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import pytest + +from tests.conftest import build_test_vibe_config +from vibe.core.config import ( + DEFAULT_TTS_MODELS, + DEFAULT_TTS_PROVIDERS, + TTSClient, + TTSModelConfig, + TTSProviderConfig, +) + + +class TestTTSConfigDefaults: + def test_default_tts_providers_loaded(self) -> None: + config = build_test_vibe_config() + assert len(config.tts_providers) == len(DEFAULT_TTS_PROVIDERS) + assert config.tts_providers[0].name == "mistral" + assert config.tts_providers[0].api_base == "https://api.mistral.ai" + + def test_default_tts_models_loaded(self) -> None: + config = build_test_vibe_config() + assert len(config.tts_models) == len(DEFAULT_TTS_MODELS) + assert config.tts_models[0].alias == "voxtral-tts" + assert config.tts_models[0].name == "voxtral-mini-tts-latest" + + def test_default_active_tts_model(self) -> None: + config = build_test_vibe_config() + assert config.active_tts_model == "voxtral-tts" + + +class TestGetActiveTTSModel: + def test_resolves_by_alias(self) -> None: + config = build_test_vibe_config() + model = config.get_active_tts_model() + assert model.alias == "voxtral-tts" + assert model.name == "voxtral-mini-tts-latest" + + def test_raises_for_unknown_alias(self) -> None: + config = build_test_vibe_config(active_tts_model="nonexistent") + with pytest.raises(ValueError, match="not found in configuration"): + config.get_active_tts_model() + + +class TestGetTTSProviderForModel: + def test_resolves_by_name(self) -> None: + config = build_test_vibe_config() + model = config.get_active_tts_model() + provider = config.get_tts_provider_for_model(model) + assert provider.name == "mistral" + assert provider.api_base == "https://api.mistral.ai" + + def test_raises_for_unknown_provider(self) -> None: + config = build_test_vibe_config( + tts_models=[ + TTSModelConfig(name="test-model", provider="nonexistent", alias="test") + ], + active_tts_model="test", + ) + model = config.get_active_tts_model() + with pytest.raises(ValueError, match="not found in configuration"): + config.get_tts_provider_for_model(model) + + +class TestTTSModelUniqueness: + def test_duplicate_aliases_raise(self) -> None: + with pytest.raises(ValueError, match="Duplicate TTS model alias"): + build_test_vibe_config( + tts_models=[ + TTSModelConfig( + name="model-a", provider="mistral", alias="same-alias" + ), + TTSModelConfig( + name="model-b", provider="mistral", alias="same-alias" + ), + ], + active_tts_model="same-alias", + ) + + +class TestTTSModelConfig: + def test_alias_defaults_to_name(self) -> None: + model = TTSModelConfig.model_validate({ + "name": "my-model", + "provider": "mistral", + }) + assert model.alias == "my-model" + + def test_explicit_alias(self) -> None: + model = TTSModelConfig( + name="my-model", provider="mistral", alias="custom-alias" + ) + assert model.alias == "custom-alias" + + def test_default_values(self) -> None: + model = TTSModelConfig(name="my-model", provider="mistral", alias="my-model") + assert model.voice == "gb_jane_neutral" + assert model.response_format == "wav" + + +class TestTTSProviderConfig: + def test_default_values(self) -> None: + provider = TTSProviderConfig(name="test") + assert provider.api_base == "https://api.mistral.ai" + assert provider.api_key_env_var == "" + assert provider.client == TTSClient.MISTRAL diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index ef083cd..dedb8d4 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -1,8 +1,11 @@ from __future__ import annotations +from pathlib import Path + import pytest from vibe.core.utils import get_server_url_from_api_base +from vibe.core.utils.io import read_safe @pytest.mark.parametrize( @@ -17,3 +20,45 @@ from vibe.core.utils import get_server_url_from_api_base ) def test_get_server_url_from_api_base(api_base, expected): assert get_server_url_from_api_base(api_base) == expected + + +class TestReadSafe: + def test_reads_utf8(self, tmp_path: Path) -> None: + f = tmp_path / "hello.txt" + f.write_text("café\n", encoding="utf-8") + assert read_safe(f) == "café\n" + + def test_falls_back_on_non_utf8(self, tmp_path: Path) -> None: + f = tmp_path / "latin.txt" + # \x81 invalid UTF-8 and undefined in CP1252 → U+FFFD on all platforms + f.write_bytes(b"maf\x81\n") + result = read_safe(f) + assert result == "maf�\n" + + def test_raise_on_error_true_utf8_succeeds(self, tmp_path: Path) -> None: + f = tmp_path / "hello.txt" + f.write_text("café\n", encoding="utf-8") + assert read_safe(f, raise_on_error=True) == "café\n" + + def test_raise_on_error_true_non_utf8_raises(self, tmp_path: Path) -> None: + f = tmp_path / "bad.txt" + # Invalid UTF-8; with raise_on_error=True we use default encoding (strict), so decode errors propagate + f.write_bytes(b"maf\x81\n") + assert read_safe(f, raise_on_error=False) == "maf�\n" + with pytest.raises(UnicodeDecodeError): + read_safe(f, raise_on_error=True) + + def test_empty_file(self, tmp_path: Path) -> None: + f = tmp_path / "empty.txt" + f.write_bytes(b"") + assert read_safe(f) == "" + + def test_binary_garbage_does_not_raise(self, tmp_path: Path) -> None: + f = tmp_path / "garbage.bin" + f.write_bytes(bytes(range(256))) + result = read_safe(f) + assert isinstance(result, str) + + def test_file_not_found_raises(self, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + read_safe(tmp_path / "nope.txt") diff --git a/tests/e2e/common.py b/tests/e2e/common.py index 18d1d5c..b352b4b 100644 --- a/tests/e2e/common.py +++ b/tests/e2e/common.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Sequence from contextlib import AbstractContextManager import io from pathlib import Path @@ -13,7 +13,7 @@ import pexpect class SpawnedVibeProcessFixture(Protocol): def __call__( - self, workdir: Path + self, workdir: Path, extra_args: Sequence[str] | None = None ) -> AbstractContextManager[tuple[pexpect.spawn, io.StringIO]]: ... diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index e57e99a..70e3a94 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterator, Sequence from contextlib import AbstractContextManager, contextmanager import io import os @@ -52,17 +52,21 @@ type SpawnedVibeContext = Iterator[tuple[pexpect.spawn, io.StringIO]] type SpawnedVibeContextManager = AbstractContextManager[ tuple[pexpect.spawn, io.StringIO] ] -type SpawnedVibeFactory = Callable[[Path], SpawnedVibeContextManager] +type SpawnedVibeFactory = Callable[ + [Path, Sequence[str] | None], SpawnedVibeContextManager +] @pytest.fixture def spawned_vibe_process() -> SpawnedVibeFactory: @contextmanager - def spawn(workdir: Path) -> SpawnedVibeContext: + def spawn( + workdir: Path, extra_args: Sequence[str] | None = None + ) -> SpawnedVibeContext: captured = io.StringIO() child = pexpect.spawn( "uv", - ["run", "vibe", "--workdir", str(workdir)], + ["run", "vibe", "--workdir", str(workdir), *(extra_args or [])], cwd=str(TESTS_ROOT.parent), env=os.environ, encoding="utf-8", diff --git a/tests/e2e/test_cli_tui_session_exit.py b/tests/e2e/test_cli_tui_session_exit.py new file mode 100644 index 0000000..42c4f6d --- /dev/null +++ b/tests/e2e/test_cli_tui_session_exit.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from collections.abc import Callable +import io +from pathlib import Path +import re +import time + +import pexpect +import pytest + +from tests.e2e.common import ( + SpawnedVibeProcessFixture, + ansi_tolerant_pattern, + strip_ansi, + wait_for_main_screen, + wait_for_request_count, +) +from tests.e2e.mock_server import StreamingMockServer + + +def _usage_by_run_factory( + request_index: int, _payload: object +) -> list[dict[str, object]]: + return [ + StreamingMockServer.build_chunk( + created=123, + delta={"role": "assistant", "content": f"Reply {request_index + 1}"}, + finish_reason=None, + ), + StreamingMockServer.build_chunk( + created=124, + delta={}, + finish_reason="stop", + usage=( + {"prompt_tokens": 11, "completion_tokens": 7} + if request_index == 0 + else {"prompt_tokens": 2, "completion_tokens": 1} + ), + ), + ] + + +def _finish_turn( + child: pexpect.spawn, + captured: io.StringIO, + expected_reply: str, + expected_request_count: int, + request_count_getter: Callable[[], int], +) -> None: + wait_for_request_count( + request_count_getter, expected_count=expected_request_count, timeout=10 + ) + child.expect(ansi_tolerant_pattern(expected_reply), timeout=10) + + start = time.monotonic() + last_change = start + last_size = len(captured.getvalue()) + + while time.monotonic() - start < 5: + try: + child.expect(r"\S", timeout=0.05) + except pexpect.TIMEOUT: + pass + + current_size = len(captured.getvalue()) + if current_size != last_size: + last_size = current_size + last_change = time.monotonic() + continue + + if time.monotonic() - last_change >= 0.3: + return + + rendered_tail = strip_ansi(captured.getvalue())[-1200:] + raise AssertionError( + f"Timed out waiting for the turn to finish.\n\nRendered tail:\n{rendered_tail}" + ) + + +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "streaming_mock_server", + [pytest.param(_usage_by_run_factory, id="fresh-usage-after-resume")], + indirect=True, +) +def test_resumed_session_prints_only_fresh_token_usage_on_exit( + streaming_mock_server: StreamingMockServer, + setup_e2e_env: None, + e2e_workdir: Path, + spawned_vibe_process: SpawnedVibeProcessFixture, +) -> None: + with spawned_vibe_process(e2e_workdir) as (child, captured): + wait_for_main_screen(child, timeout=15) + child.send("First run") + child.send("\r") + + _finish_turn( + child, + captured, + expected_reply="Reply 1", + expected_request_count=1, + request_count_getter=lambda: len(streaming_mock_server.requests), + ) + + child.sendcontrol("c") + child.expect(pexpect.EOF, timeout=10) + + first_output = strip_ansi(captured.getvalue()) + resume_match = re.search(r"Or: vibe --resume ([0-9a-f-]+)", first_output) + assert resume_match is not None + session_id = resume_match.group(1) + assert ( + "Total tokens used this session: input=11 output=7 (total=18)" in first_output + ) + + with spawned_vibe_process(e2e_workdir, extra_args=["--resume", session_id]) as ( + resumed_child, + resumed_captured, + ): + wait_for_main_screen(resumed_child, timeout=15) + resumed_child.send("Second run") + resumed_child.send("\r") + + _finish_turn( + resumed_child, + resumed_captured, + expected_reply="Reply 2", + expected_request_count=2, + request_count_getter=lambda: len(streaming_mock_server.requests), + ) + + resumed_child.sendcontrol("c") + resumed_child.expect(pexpect.EOF, timeout=10) + + second_output = strip_ansi(resumed_captured.getvalue()) + + assert "Total tokens used this session: input=2 output=1 (total=3)" in second_output diff --git a/tests/mock/mock_backend_factory.py b/tests/mock/mock_backend_factory.py index c15ad65..b4e4341 100644 --- a/tests/mock/mock_backend_factory.py +++ b/tests/mock/mock_backend_factory.py @@ -2,8 +2,8 @@ from __future__ import annotations from contextlib import contextmanager -from vibe.core.config import Backend from vibe.core.llm.backend.factory import BACKEND_FACTORY +from vibe.core.types import Backend @contextmanager diff --git a/tests/session/test_session_loader.py b/tests/session/test_session_loader.py index 1911ba2..beb4017 100644 --- a/tests/session/test_session_loader.py +++ b/tests/session/test_session_loader.py @@ -1008,12 +1008,13 @@ class TestSessionLoaderUTF8Encoding: session_folder = session_dir / "test_20230101_120000_latin100" session_folder.mkdir() + # \x81 invalid UTF-8 and undefined in CP1252 → U+FFFD on all platforms metadata_content = { "session_id": "latin1-test", "start_time": "2023-01-01T12:00:00Z", "end_time": "2023-01-01T12:05:00Z", "username": "testuser", - "environment": {"working_directory": "/home/user/café_project"}, + "environment": {"working_directory": "/home/user/caf\x81_project"}, "git_commit": None, "git_branch": None, } @@ -1027,7 +1028,7 @@ class TestSessionLoaderUTF8Encoding: metadata = SessionLoader.load_metadata(session_folder) assert metadata.session_id == "latin1-test" - assert metadata.environment["working_directory"] == "/home/user/caf_project" + assert metadata.environment["working_directory"] == "/home/user/caf�_project" def test_load_session_with_utf8_metadata_and_messages( self, session_config: SessionLoggingConfig 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 4951096..50fb83e 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 @@ -40,7 +40,7 @@ .terminal-r6 { fill: #d2d2d2 } .terminal-r7 { fill: #292929 } .terminal-r8 { fill: #4b4e55 } -.terminal-r9 { fill: #d0b344;font-weight: bold } +.terminal-r9 { fill: #98a84b;font-weight: bold } .terminal-r10 { fill: #9a9b99 } diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_escape_closes.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_escape_closes.svg new file mode 100644 index 0000000..5c1e5c4 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_escape_closes.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ConfigTestApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +Configuration opened... +Configuration closed (no changes saved). + + +┌──────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_initial.svg new file mode 100644 index 0000000..5ff9950 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_initial.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ConfigTestApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +Configuration opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +Settings + +Model: devstral-latest +Auto-copy: On +Autocomplete watcher (may delay first autocompletion): Off + +↑↓ Navigate  Enter Select/Toggle  Esc Exit +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_navigate_down.svg new file mode 100644 index 0000000..a91a8fe --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_navigate_down.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ConfigTestApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +Configuration opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +Settings + +Model: devstral-latest +Auto-copy: On +Autocomplete watcher (may delay first autocompletion): Off + +↑↓ Navigate  Enter Select/Toggle  Esc Exit +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_toggle_autocopy.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_toggle_autocopy.svg new file mode 100644 index 0000000..b241c10 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_toggle_autocopy.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ConfigTestApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +Configuration opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +Settings + +Model: devstral-latest +Auto-copy: Off +Autocomplete watcher (may delay first autocompletion): Off + +↑↓ Navigate  Enter Select/Toggle  Esc Exit +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_feedback_bar/test_snapshot_feedback_bar_visible.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_feedback_bar/test_snapshot_feedback_bar_visible.svg new file mode 100644 index 0000000..11c4b7b --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_feedback_bar/test_snapshot_feedback_bar_visible.svg @@ -0,0 +1,202 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FeedbackBarSnapshotApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +Hello + +Sure, I can help with that. + +How is Vibe doing so far?  1: good  2: fine  3: bad +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir6% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_escape_cancels.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_escape_cancels.svg new file mode 100644 index 0000000..61c79b7 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_escape_cancels.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ModelPickerTestApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣5 models · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + +┌──────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_initial.svg new file mode 100644 index 0000000..af465f2 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_initial.svg @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ModelPickerTestApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣5 models · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +Select Model +  mistral-large +› devstral +  codestral +  mistral-small +  local + +↑↓ Navigate  Enter Select  Esc Cancel +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_navigate_down.svg new file mode 100644 index 0000000..e667a4c --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_navigate_down.svg @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ModelPickerTestApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣5 models · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +Select Model +  mistral-large +› devstral +  codestral +  mistral-small +  local + +↑↓ Navigate  Enter Select  Esc Cancel +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_select_different_model.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_select_different_model.svg new file mode 100644 index 0000000..e334e61 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_select_different_model.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ModelPickerTestApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +Configuration reloaded. + + +┌──────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_idle_after_speaking.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_idle_after_speaking.svg new file mode 100644 index 0000000..9e9726b --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_idle_after_speaking.svg @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NarratorFlowApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +Hello + +Hello! I can help you. + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir6% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_speaking.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_speaking.svg new file mode 100644 index 0000000..dc1df20 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_speaking.svg @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NarratorFlowApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +Hello + +Hello! I can help you. + +▂▅▇ speaking esc to stop +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir6% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_summarizing.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_summarizing.svg new file mode 100644 index 0000000..96d4d43 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_summarizing.svg @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NarratorFlowApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +Hello + +Hello! I can help you. + + summarizing esc to stop +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir6% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_disable.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_disable.svg index 289a82b..1028a3a 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_disable.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_disable.svg @@ -180,13 +180,13 @@ - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + - -/voice +/voice +Voice settings opened... Voice mode disabled. diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_enable.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_enable.svg index 8023575..3c38282 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_enable.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_enable.svg @@ -180,13 +180,13 @@ - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + - -/voice +/voice +Voice settings opened... Voice mode enabled. Press ctrl+r to start recording. diff --git a/tests/snapshots/test_ui_snapshot_config_app.py b/tests/snapshots/test_ui_snapshot_config_app.py new file mode 100644 index 0000000..7eae2b5 --- /dev/null +++ b/tests/snapshots/test_ui_snapshot_config_app.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from textual.pilot import Pilot + +from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp +from tests.snapshots.snap_compare import SnapCompare + + +class ConfigTestApp(BaseSnapshotTestApp): + async def on_mount(self) -> None: + await super().on_mount() + await self._switch_to_config_app() + + +def test_snapshot_config_initial(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_config_app.py:ConfigTestApp", + terminal_size=(100, 36), + run_before=run_before, + ) + + +def test_snapshot_config_navigate_down(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + await pilot.press("down") + await pilot.pause(0.1) + + assert snap_compare( + "test_ui_snapshot_config_app.py:ConfigTestApp", + terminal_size=(100, 36), + run_before=run_before, + ) + + +def test_snapshot_config_toggle_autocopy(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + await pilot.press("down") + await pilot.press("enter") + await pilot.pause(0.1) + + assert snap_compare( + "test_ui_snapshot_config_app.py:ConfigTestApp", + terminal_size=(100, 36), + run_before=run_before, + ) + + +def test_snapshot_config_escape_closes(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + await pilot.press("escape") + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_config_app.py:ConfigTestApp", + terminal_size=(100, 36), + run_before=run_before, + ) diff --git a/tests/snapshots/test_ui_snapshot_feedback_bar.py b/tests/snapshots/test_ui_snapshot_feedback_bar.py new file mode 100644 index 0000000..b6c0487 --- /dev/null +++ b/tests/snapshots/test_ui_snapshot_feedback_bar.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from textual.pilot import Pilot + +from tests.mock.utils import mock_llm_chunk +from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp +from tests.snapshots.snap_compare import SnapCompare +from tests.stubs.fake_backend import FakeBackend + + +@pytest.fixture(autouse=True) +def _enable_feedback_bar(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "vibe.cli.textual_ui.widgets.feedback_bar.FEEDBACK_PROBABILITY", 1 + ) + + +class FeedbackBarSnapshotApp(BaseSnapshotTestApp): + def __init__(self) -> None: + fake_backend = FakeBackend( + mock_llm_chunk( + content="Sure, I can help with that.", + prompt_tokens=10_000, + completion_tokens=2_500, + ) + ) + super().__init__(backend=fake_backend) + + +def test_snapshot_feedback_bar_visible(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + with patch( + "vibe.cli.textual_ui.widgets.feedback_bar.random.random", return_value=0 + ): + await pilot.press(*"Hello") + await pilot.press("enter") + await pilot.pause(0.4) + + assert snap_compare( + "test_ui_snapshot_feedback_bar.py:FeedbackBarSnapshotApp", + terminal_size=(120, 36), + run_before=run_before, + ) diff --git a/tests/snapshots/test_ui_snapshot_model_picker.py b/tests/snapshots/test_ui_snapshot_model_picker.py new file mode 100644 index 0000000..d707188 --- /dev/null +++ b/tests/snapshots/test_ui_snapshot_model_picker.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from unittest.mock import patch + +from textual.pilot import Pilot + +from tests.conftest import build_test_vibe_config +from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp +from tests.snapshots.snap_compare import SnapCompare +from vibe.core.config._settings import ModelConfig + + +def _model_picker_config(): + models = [ + ModelConfig( + name="mistral-large-latest", provider="mistral", alias="mistral-large" + ), + ModelConfig(name="devstral-latest", provider="mistral", alias="devstral"), + ModelConfig(name="codestral-latest", provider="mistral", alias="codestral"), + ModelConfig( + name="mistral-small-latest", provider="mistral", alias="mistral-small" + ), + ModelConfig(name="devstral", provider="llamacpp", alias="local"), + ] + return build_test_vibe_config( + models=models, + active_model="devstral", + disable_welcome_banner_animation=True, + displayed_workdir="/test/workdir", + ) + + +class ModelPickerTestApp(BaseSnapshotTestApp): + def __init__(self): + super().__init__(config=_model_picker_config()) + + async def on_mount(self) -> None: + await super().on_mount() + await self._switch_to_model_picker_app() + + +def test_snapshot_model_picker_initial(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_model_picker.py:ModelPickerTestApp", + terminal_size=(100, 36), + run_before=run_before, + ) + + +def test_snapshot_model_picker_navigate_down(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + await pilot.press("down") + await pilot.pause(0.1) + + assert snap_compare( + "test_ui_snapshot_model_picker.py:ModelPickerTestApp", + terminal_size=(100, 36), + run_before=run_before, + ) + + +def test_snapshot_model_picker_select_different_model( + snap_compare: SnapCompare, +) -> None: + """Select the second model and verify the picker closes back to input.""" + + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + await pilot.press("down") + await pilot.press("enter") + await pilot.pause(0.2) + + with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates"): + assert snap_compare( + "test_ui_snapshot_model_picker.py:ModelPickerTestApp", + terminal_size=(100, 36), + run_before=run_before, + ) + + +def test_snapshot_model_picker_escape_cancels(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + await pilot.press("escape") + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_model_picker.py:ModelPickerTestApp", + terminal_size=(100, 36), + run_before=run_before, + ) diff --git a/tests/snapshots/test_ui_snapshot_narrator_flow.py b/tests/snapshots/test_ui_snapshot_narrator_flow.py new file mode 100644 index 0000000..142882b --- /dev/null +++ b/tests/snapshots/test_ui_snapshot_narrator_flow.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import asyncio +from typing import Any, cast + +from textual.pilot import Pilot + +from tests.conftest import build_test_vibe_config +from tests.mock.utils import mock_llm_chunk +from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp +from tests.snapshots.snap_compare import SnapCompare +from tests.stubs.fake_audio_player import FakeAudioPlayer +from tests.stubs.fake_backend import FakeBackend +from tests.stubs.fake_tts_client import FakeTTSClient +import vibe.cli.textual_ui.widgets.narrator_status as narrator_status_mod +from vibe.cli.textual_ui.widgets.narrator_status import NarratorState, NarratorStatus +from vibe.cli.turn_summary import TurnSummaryTracker + +narrator_status_mod.SHRINK_FRAMES = "█" +narrator_status_mod.BAR_FRAMES = ["▂▅▇"] +from vibe.core.config import ModelConfig +from vibe.core.tts.tts_client_port import TTSResult +from vibe.core.types import LLMChunk + +_TEST_MODEL = ModelConfig(name="test-model", provider="test", alias="test-model") + + +def _narrator_config(): + return build_test_vibe_config( + narrator_enabled=True, + disable_welcome_banner_animation=True, + displayed_workdir="/test/workdir", + ) + + +class GatedBackend(FakeBackend): + def __init__(self, chunks: LLMChunk) -> None: + super().__init__(chunks) + self._gate = asyncio.Event() + + def release(self) -> None: + self._gate.set() + + async def complete(self, **kwargs: Any) -> LLMChunk: + await self._gate.wait() + return await super().complete(**kwargs) + + +class GatedTTSClient(FakeTTSClient): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._gate = asyncio.Event() + + def release(self) -> None: + self._gate.set() + + async def speak(self, text: str) -> TTSResult: + await self._gate.wait() + return await super().speak(text) + + +class NarratorFlowApp(BaseSnapshotTestApp): + def __init__(self) -> None: + self.summary_gate = GatedBackend( + mock_llm_chunk(content="Summary of the conversation") + ) + self.tts_gate = GatedTTSClient() + super().__init__( + config=_narrator_config(), + backend=FakeBackend( + mock_llm_chunk( + content="Hello! I can help you.", + prompt_tokens=10_000, + completion_tokens=2_500, + ) + ), + ) + self._tts_client = self.tts_gate + self._audio_player = FakeAudioPlayer() + self._turn_summary = TurnSummaryTracker( + backend=self.summary_gate, + model=_TEST_MODEL, + on_summary=self._on_turn_summary, + ) + + +def test_snapshot_narrator_summarizing(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + app = cast(NarratorFlowApp, pilot.app) + # Send message and wait for agent response to complete + await pilot.press(*"Hello") + await pilot.press("enter") + await pilot.pause(0.5) + # end_turn has fired, SUMMARIZING is set, summary backend is gated + assert app.summary_gate._gate.is_set() is False + # Freeze animation at frame 0 for deterministic snapshot + app.query_one(NarratorStatus)._stop_timer() + + assert snap_compare( + "test_ui_snapshot_narrator_flow.py:NarratorFlowApp", + terminal_size=(120, 36), + run_before=run_before, + ) + + +def test_snapshot_narrator_speaking(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + app = cast(NarratorFlowApp, pilot.app) + await pilot.press(*"Hello") + await pilot.press("enter") + await pilot.pause(0.5) + # Release summary gate → summary resolves → speak task starts → blocks on TTS gate + app.summary_gate.release() + await pilot.pause(0.2) + # Release TTS gate → TTS resolves → SPEAKING set + app.tts_gate.release() + await pilot.pause(0.2) + # Freeze animation at frame 0 for deterministic snapshot + app.query_one(NarratorStatus)._stop_timer() + + assert snap_compare( + "test_ui_snapshot_narrator_flow.py:NarratorFlowApp", + terminal_size=(120, 36), + run_before=run_before, + ) + + +def test_snapshot_narrator_idle_after_speaking(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + app = cast(NarratorFlowApp, pilot.app) + await pilot.press(*"Hello") + await pilot.press("enter") + await pilot.pause(0.5) + # Release both gates to reach SPEAKING + app.summary_gate.release() + await pilot.pause(0.2) + app.tts_gate.release() + await pilot.pause(0.2) + # Simulate playback finishing (same thread, so call directly) + app._audio_player.stop() + app._set_narrator_state(NarratorState.IDLE) + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_narrator_flow.py:NarratorFlowApp", + terminal_size=(120, 36), + run_before=run_before, + ) diff --git a/tests/snapshots/test_ui_snapshot_voice_mode.py b/tests/snapshots/test_ui_snapshot_voice_mode.py index dadad2e..40b6111 100644 --- a/tests/snapshots/test_ui_snapshot_voice_mode.py +++ b/tests/snapshots/test_ui_snapshot_voice_mode.py @@ -2,6 +2,7 @@ from __future__ import annotations from textual.pilot import Pilot +from tests.conftest import build_test_vibe_config from tests.mock.utils import mock_llm_chunk from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp from tests.snapshots.snap_compare import SnapCompare @@ -17,7 +18,14 @@ class VoiceEnableApp(BaseSnapshotTestApp): class VoiceDisableApp(BaseSnapshotTestApp): def __init__(self) -> None: - super().__init__(voice_manager=FakeVoiceManager(is_voice_ready=True)) + config = build_test_vibe_config( + disable_welcome_banner_animation=True, + displayed_workdir="/test/workdir", + voice_mode_enabled=True, + ) + super().__init__( + config=config, voice_manager=FakeVoiceManager(is_voice_ready=True) + ) class RecordingActiveApp(BaseSnapshotTestApp): @@ -49,6 +57,10 @@ def test_snapshot_voice_enable(snap_compare: SnapCompare) -> None: await pilot.press(*"/voice") await pilot.press("enter") await pilot.pause(0.4) + await pilot.press("space") + await pilot.pause(0.2) + await pilot.press("escape") + await pilot.pause(0.4) assert snap_compare( "test_ui_snapshot_voice_mode.py:VoiceEnableApp", @@ -62,6 +74,10 @@ def test_snapshot_voice_disable(snap_compare: SnapCompare) -> None: await pilot.press(*"/voice") await pilot.press("enter") await pilot.pause(0.4) + await pilot.press("space") + await pilot.pause(0.2) + await pilot.press("escape") + await pilot.pause(0.4) assert snap_compare( "test_ui_snapshot_voice_mode.py:VoiceDisableApp", diff --git a/tests/stubs/fake_audio_player.py b/tests/stubs/fake_audio_player.py new file mode 100644 index 0000000..56dc2c5 --- /dev/null +++ b/tests/stubs/fake_audio_player.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from collections.abc import Callable + +from vibe.core.audio_player import AlreadyPlayingError +from vibe.core.audio_player.audio_player_port import AudioFormat + + +class FakeAudioPlayer: + def __init__(self) -> None: + self._playing = False + self._on_finished: Callable[[], object] | None = None + + @property + def is_playing(self) -> bool: + return self._playing + + def play( + self, + audio_data: bytes, + audio_format: AudioFormat, + *, + on_finished: Callable[[], object] | None = None, + ) -> None: + if self._playing: + raise AlreadyPlayingError("Already playing") + self._playing = True + self._on_finished = on_finished + + def stop(self) -> None: + self._playing = False + + def simulate_finished(self) -> None: + self._playing = False + if self._on_finished is not None: + self._on_finished() diff --git a/tests/stubs/fake_client.py b/tests/stubs/fake_client.py index ef6294b..9660bb7 100644 --- a/tests/stubs/fake_client.py +++ b/tests/stubs/fake_client.py @@ -6,7 +6,7 @@ from acp import ( Agent as AcpAgent, Client, CreateTerminalResponse, - KillTerminalCommandResponse, + KillTerminalResponse, ReadTextFileResponse, ReleaseTerminalResponse, RequestPermissionResponse, @@ -112,7 +112,7 @@ class FakeClient(Client): async def kill_terminal( self, session_id: str, terminal_id: str, **kwargs: Any - ) -> KillTerminalCommandResponse | None: + ) -> KillTerminalResponse | None: raise NotImplementedError() async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]: diff --git a/tests/stubs/fake_tts_client.py b/tests/stubs/fake_tts_client.py new file mode 100644 index 0000000..d4745c0 --- /dev/null +++ b/tests/stubs/fake_tts_client.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Any + +from vibe.core.tts.tts_client_port import TTSResult + + +class FakeTTSClient: + def __init__( + self, *_args: Any, result: TTSResult | None = None, **_kwargs: Any + ) -> None: + self._result: TTSResult = result or TTSResult(audio_data=b"fake-audio") + + def set_result(self, result: TTSResult) -> None: + self._result = result + + async def speak(self, text: str) -> TTSResult: + return self._result + + async def close(self) -> None: + pass diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py index 8a25bd0..5b60c05 100644 --- a/tests/test_agent_backend.py +++ b/tests/test_agent_backend.py @@ -10,11 +10,17 @@ from mcp.types import ( ) import pytest -from tests.conftest import build_test_agent_loop, build_test_vibe_config +from tests.conftest import ( + build_test_agent_loop, + build_test_vibe_config, + make_test_models, +) from tests.mock.utils import mock_llm_chunk from tests.stubs.fake_backend import FakeBackend -from vibe.core.config import Backend, ModelConfig, ProviderConfig, VibeConfig -from vibe.core.types import EntrypointMetadata +from vibe.core.agents.models import BuiltinAgentName +from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig +from vibe.core.tools.base import BaseToolConfig, ToolPermission +from vibe.core.types import Backend, EntrypointMetadata, FunctionCall, ToolCall def _two_model_vibe_config(active_model: str) -> VibeConfig: @@ -119,7 +125,12 @@ async def test_passes_session_id_to_backend(vibe_config: VibeConfig): [_ async for _ in agent.act("Hello")] assert len(backend.requests_metadata) > 0 - assert backend.requests_metadata[0] == {"session_id": agent.session_id} + meta = backend.requests_metadata[0] + assert meta is not None + assert meta["session_id"] == agent.session_id + assert "message_id" in meta + assert meta["is_user_prompt"] == "true" + assert meta["call_type"] == "main_call" @pytest.mark.asyncio @@ -141,13 +152,16 @@ async def test_passes_entrypoint_metadata_to_backend(vibe_config: VibeConfig): [_ async for _ in agent.act("Hello")] assert len(backend.requests_metadata) > 0 - assert backend.requests_metadata[0] == { - "agent_entrypoint": "acp", - "agent_version": "2.0.0", - "client_name": "vibe_ide", - "client_version": "0.5.0", - "session_id": agent.session_id, - } + meta = backend.requests_metadata[0] + assert meta is not None + assert meta["agent_entrypoint"] == "acp" + assert meta["agent_version"] == "2.0.0" + assert meta["client_name"] == "vibe_ide" + assert meta["client_version"] == "0.5.0" + assert meta["session_id"] == agent.session_id + assert "message_id" in meta + assert meta["is_user_prompt"] == "true" + assert meta["call_type"] == "main_call" @pytest.mark.asyncio @@ -197,3 +211,108 @@ async def test_mcp_sampling_handler_uses_updated_config_when_agent_config_change result2 = await handler(context, params) assert isinstance(result2, CreateMessageResult) assert result2.model == "devstral-small-latest" + + +def _generic_provider_vibe_config() -> VibeConfig: + """VibeConfig with generic backend so no metadata header is sent.""" + providers = [ + ProviderConfig( + name="mistral", + api_base="https://api.mistral.ai/v1", + api_key_env_var="MISTRAL_API_KEY", + backend=Backend.GENERIC, + ) + ] + return build_test_vibe_config(providers=providers) + + +@pytest.mark.asyncio +async def test_mistral_metadata_header_is_user_prompt_per_turn() -> None: + """First LLM call in a turn has is_user_prompt=True; second call (after tools) has is_user_prompt=False.""" + tool_call = ToolCall( + id="call_1", + index=0, + function=FunctionCall(name="todo", arguments='{"action": "read"}'), + ) + backend = FakeBackend([ + [mock_llm_chunk(content="Checking todos.", tool_calls=[tool_call])], + [mock_llm_chunk(content="Here are your todos.")], + ]) + config = build_test_vibe_config( + providers=[ + ProviderConfig( + name="mistral", + api_base="https://api.mistral.ai/v1", + api_key_env_var="MISTRAL_API_KEY", + backend=Backend.MISTRAL, + ) + ], + enabled_tools=["todo"], + tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)}, + ) + agent = build_test_agent_loop( + config=config, backend=backend, agent_name=BuiltinAgentName.AUTO_APPROVE + ) + + [_ async for _ in agent.act("What's on my todo list?")] + + assert len(backend.requests_metadata) == 2 + first_metadata = backend.requests_metadata[0] + second_metadata = backend.requests_metadata[1] + assert first_metadata is not None and "is_user_prompt" in first_metadata + assert second_metadata is not None and "is_user_prompt" in second_metadata + assert first_metadata["is_user_prompt"] == "true" + assert second_metadata["is_user_prompt"] == "false" + assert first_metadata["call_type"] == "main_call" + assert second_metadata["call_type"] == "secondary_call" + + +@pytest.mark.asyncio +async def test_auto_compact_internal_chat_has_is_user_prompt_false_then_user_turn_true() -> ( + None +): + """Compact's internal _chat() sends is_user_prompt=False; the following user turn sends is_user_prompt=True.""" + backend = FakeBackend([ + [mock_llm_chunk(content="")], + [mock_llm_chunk(content="")], + ]) + config = build_test_vibe_config( + models=make_test_models(auto_compact_threshold=1), + providers=[ + ProviderConfig( + name="mistral", + api_base="https://api.mistral.ai/v1", + api_key_env_var="MISTRAL_API_KEY", + backend=Backend.MISTRAL, + ) + ], + ) + agent = build_test_agent_loop(config=config, backend=backend) + agent.stats.context_tokens = 2 + + [_ async for _ in agent.act("Hello")] + + assert len(backend.requests_metadata) == 2 + compact_metadata = backend.requests_metadata[0] + user_turn_metadata = backend.requests_metadata[1] + assert compact_metadata is not None and "is_user_prompt" in compact_metadata + assert user_turn_metadata is not None and "is_user_prompt" in user_turn_metadata + assert compact_metadata["is_user_prompt"] == "false" + assert user_turn_metadata["is_user_prompt"] == "true" + assert compact_metadata["call_type"] == "secondary_call" + assert user_turn_metadata["call_type"] == "main_call" + + +@pytest.mark.asyncio +async def test_generic_provider_has_no_metadata_header() -> None: + """Non-Mistral provider does not send the metadata header.""" + backend = FakeBackend([mock_llm_chunk(content="Response")]) + config = _generic_provider_vibe_config() + agent = build_test_agent_loop(config=config, backend=backend) + + [_ async for _ in agent.act("Hello")] + + assert len(backend.requests_extra_headers) == 1 + headers = backend.requests_extra_headers[0] + assert headers is not None + assert "metadata" not in headers diff --git a/tests/test_agent_observer_streaming.py b/tests/test_agent_observer_streaming.py index 926adef..805aead 100644 --- a/tests/test_agent_observer_streaming.py +++ b/tests/test_agent_observer_streaming.py @@ -22,7 +22,6 @@ from vibe.core.middleware import ( MiddlewareResult, ResetReason, ) -from vibe.core.tools.base import BaseToolConfig, ToolPermission from vibe.core.tools.builtins.todo import TodoArgs from vibe.core.types import ( ApprovalResponse, @@ -54,9 +53,7 @@ class InjectBeforeMiddleware: def make_config( - *, - enabled_tools: list[str] | None = None, - tools: dict[str, BaseToolConfig] | None = None, + *, enabled_tools: list[str] | None = None, tools: dict[str, dict] | None = None ) -> VibeConfig: return build_test_vibe_config( system_prompt_id="tests", @@ -218,8 +215,7 @@ async def test_act_handles_streaming_with_tool_call_events_in_sequence() -> None ]) agent = build_test_agent_loop( config=make_config( - enabled_tools=["todo"], - tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)}, + enabled_tools=["todo"], tools={"todo": {"permission": "always"}} ), backend=backend, agent_name=BuiltinAgentName.AUTO_APPROVE, @@ -268,8 +264,7 @@ async def test_act_handles_tool_call_chunk_with_content() -> None: ]) agent = build_test_agent_loop( config=make_config( - enabled_tools=["todo"], - tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)}, + enabled_tools=["todo"], tools={"todo": {"permission": "always"}} ), backend=backend, agent_name=BuiltinAgentName.AUTO_APPROVE, @@ -324,8 +319,7 @@ async def test_act_merges_streamed_tool_call_arguments() -> None: ]) agent = build_test_agent_loop( config=make_config( - enabled_tools=["todo"], - tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)}, + enabled_tools=["todo"], tools={"todo": {"permission": "always"}} ), backend=backend, agent_name=BuiltinAgentName.AUTO_APPROVE, @@ -387,8 +381,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None: ]) agent = build_test_agent_loop( config=make_config( - enabled_tools=["todo"], - tools={"todo": BaseToolConfig(permission=ToolPermission.ASK)}, + enabled_tools=["todo"], tools={"todo": {"permission": "ask"}} ), backend=backend, agent_name=BuiltinAgentName.DEFAULT, @@ -398,7 +391,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None: agent.middleware_pipeline.add(middleware) async def _reject_callback( - _name: str, _args: BaseModel, _id: str + _name: str, _args: BaseModel, _id: str, _rp: list | None = None ) -> tuple[ApprovalResponse, str | None]: return ( ApprovalResponse.NO, diff --git a/tests/test_agent_stats.py b/tests/test_agent_stats.py index e2eda68..7050892 100644 --- a/tests/test_agent_stats.py +++ b/tests/test_agent_stats.py @@ -13,16 +13,16 @@ from tests.mock.utils import mock_llm_chunk from tests.stubs.fake_backend import FakeBackend from vibe.core.agents.models import BuiltinAgentName from vibe.core.config import ( - Backend, ModelConfig, ProviderConfig, SessionLoggingConfig, VibeConfig, ) -from vibe.core.tools.base import BaseToolConfig, ToolPermission +from vibe.core.tools.base import ToolPermission from vibe.core.types import ( AgentStats, AssistantEvent, + Backend, CompactEndEvent, CompactStartEvent, FunctionCall, @@ -95,7 +95,7 @@ def make_config( models=models, providers=providers, enabled_tools=enabled_tools or [], - tools={"todo": BaseToolConfig(permission=todo_permission)}, + tools={"todo": {"permission": todo_permission.value}}, ) diff --git a/tests/test_agent_tool_call.py b/tests/test_agent_tool_call.py index 0f403f6..8e8af18 100644 --- a/tests/test_agent_tool_call.py +++ b/tests/test_agent_tool_call.py @@ -13,7 +13,7 @@ from tests.stubs.fake_tool import FakeTool from vibe.core.agent_loop import AgentLoop from vibe.core.agents.models import BuiltinAgentName from vibe.core.config import VibeConfig -from vibe.core.tools.base import BaseToolConfig, ToolPermission +from vibe.core.tools.base import ToolPermission from vibe.core.tools.builtins.todo import TodoItem from vibe.core.types import ( ApprovalCallback, @@ -37,7 +37,7 @@ async def act_and_collect_events(agent_loop: AgentLoop, prompt: str) -> list[Bas def make_config(todo_permission: ToolPermission = ToolPermission.ALWAYS) -> VibeConfig: return build_test_vibe_config( enabled_tools=["todo"], - tools={"todo": BaseToolConfig(permission=todo_permission)}, + tools={"todo": {"permission": todo_permission.value}}, system_prompt_id="tests", include_project_context=False, include_prompt_detail=False, @@ -166,7 +166,7 @@ async def test_tool_call_requires_approval_if_not_auto_approved( @pytest.mark.asyncio async def test_tool_call_approved_by_callback(telemetry_events: list[dict]) -> None: async def approval_callback( - _tool_name: str, _args: BaseModel, _tool_call_id: str + _tool_name: str, _args: BaseModel, _tool_call_id: str, _rp: list | None = None ) -> tuple[ApprovalResponse, str | None]: return (ApprovalResponse.YES, None) @@ -210,7 +210,7 @@ async def test_tool_call_rejected_when_auto_approve_disabled_and_rejected_by_cal custom_feedback = "User declined tool execution" async def approval_callback( - _tool_name: str, _args: BaseModel, _tool_call_id: str + _tool_name: str, _args: BaseModel, _tool_call_id: str, _rp: list | None = None ) -> tuple[ApprovalResponse, str | None]: return (ApprovalResponse.NO, custom_feedback) @@ -299,14 +299,14 @@ async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> No agent_ref: AgentLoop | None = None async def approval_callback( - tool_name: str, _args: BaseModel, _tool_call_id: str + tool_name: str, _args: BaseModel, _tool_call_id: str, _rp: list | None = None ) -> tuple[ApprovalResponse, str | None]: callback_invocations.append(tool_name) # Set permission to ALWAYS for this tool (simulating the new behavior) assert agent_ref is not None if tool_name not in agent_ref.config.tools: - agent_ref.config.tools[tool_name] = BaseToolConfig() - agent_ref.config.tools[tool_name].permission = ToolPermission.ALWAYS + agent_ref.config.tools[tool_name] = {} + agent_ref.config.tools[tool_name]["permission"] = "always" return (ApprovalResponse.YES, None) agent_loop = make_agent_loop( @@ -596,7 +596,7 @@ async def test_parallel_tool_calls_with_approval_callback( approval_calls: list[str] = [] async def approval_callback( - tool_name: str, _args: BaseModel, tool_call_id: str + tool_name: str, _args: BaseModel, tool_call_id: str, _rp: list | None = None ) -> tuple[ApprovalResponse, str | None]: approval_calls.append(tool_call_id) return (ApprovalResponse.YES, None) @@ -640,7 +640,7 @@ async def test_parallel_approvals_can_run_concurrently() -> None: max_concurrency = 0 async def approval_callback( - tool_name: str, _args: BaseModel, tool_call_id: str + tool_name: str, _args: BaseModel, tool_call_id: str, _rp: list | None = None ) -> tuple[ApprovalResponse, str | None]: nonlocal concurrency, max_concurrency concurrency += 1 @@ -674,7 +674,7 @@ async def test_parallel_mixed_approval_and_rejection( """One tool approved, one rejected — both should produce correct events.""" async def approval_callback( - tool_name: str, _args: BaseModel, tool_call_id: str + tool_name: str, _args: BaseModel, tool_call_id: str, _rp: list | None = None ) -> tuple[ApprovalResponse, str | None]: if tool_call_id == "call_yes": return (ApprovalResponse.YES, None) @@ -813,7 +813,7 @@ async def test_parallel_all_permission_never() -> None: approval_calls: list[str] = [] async def approval_callback( - tool_name: str, _args: BaseModel, tool_call_id: str + tool_name: str, _args: BaseModel, tool_call_id: str, _rp: list | None = None ) -> tuple[ApprovalResponse, str | None]: approval_calls.append(tool_call_id) return (ApprovalResponse.YES, None) diff --git a/tests/test_agents.py b/tests/test_agents.py index 7496ec7..9c38415 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -160,6 +160,32 @@ class TestAgentProfile: class TestAgentApplyToConfig: + def test_profile_disabled_tools_are_merged_with_base_config(self) -> None: + base = VibeConfig( + include_project_context=False, + include_prompt_detail=False, + disabled_tools=["ask_user_question"], + ) + + result = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].apply_to_config(base) + + assert set(result.disabled_tools) == {"ask_user_question", "exit_plan_mode"} + + def test_profile_disabled_tools_preserve_user_disabled_tools(self) -> None: + base = VibeConfig( + include_project_context=False, + include_prompt_detail=False, + disabled_tools=["ask_user_question", "custom_tool"], + ) + + result = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].apply_to_config(base) + + assert set(result.disabled_tools) == { + "ask_user_question", + "custom_tool", + "exit_plan_mode", + } + def test_custom_prompt_found_in_global_when_missing_from_project( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -204,8 +230,9 @@ class TestAgentApplyToConfig: class TestAgentProfileOverrides: - def test_default_agent_has_no_overrides(self) -> None: - assert BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].overrides == {} + def test_default_agent_disables_exit_plan_mode(self) -> None: + overrides = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].overrides + assert "exit_plan_mode" in overrides.get("base_disabled", []) def test_auto_approve_agent_sets_auto_approve(self) -> None: overrides = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].overrides diff --git a/tests/test_cli_programmatic_preload.py b/tests/test_cli_programmatic_preload.py index 58c0d24..12ecb43 100644 --- a/tests/test_cli_programmatic_preload.py +++ b/tests/test_cli_programmatic_preload.py @@ -7,8 +7,7 @@ from tests.mock.mock_backend_factory import mock_backend_factory from tests.mock.utils import mock_llm_chunk from tests.stubs.fake_backend import FakeBackend from vibe.core import run_programmatic -from vibe.core.config import Backend -from vibe.core.types import LLMMessage, OutputFormat, Role +from vibe.core.types import Backend, LLMMessage, OutputFormat, Role class SpyStreamingFormatter: diff --git a/tests/test_reasoning_content.py b/tests/test_reasoning_content.py index 46f6772..e5108a8 100644 --- a/tests/test_reasoning_content.py +++ b/tests/test_reasoning_content.py @@ -158,7 +158,7 @@ class TestMistralMapperPrepareMessage: assert isinstance(result, AssistantMessage) assert isinstance(result.content, list) - assert len(result.content) == 2 + assert len(result.content) == 1 think_chunk = result.content[0] assert isinstance(think_chunk, ThinkChunk) @@ -168,10 +168,6 @@ class TestMistralMapperPrepareMessage: assert isinstance(inner_chunk, TextChunk) assert inner_chunk.text == "Just thinking..." - text_chunk = result.content[1] - assert isinstance(text_chunk, TextChunk) - assert text_chunk.text == "" - class TestGenericBackendReasoningContent: @pytest.mark.asyncio diff --git a/tests/test_tracing.py b/tests/test_tracing.py new file mode 100644 index 0000000..fa12a41 --- /dev/null +++ b/tests/test_tracing.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import MagicMock, patch + +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ( + SimpleSpanProcessor, + SpanExporter, + SpanExportResult, +) +from opentelemetry.trace import StatusCode +import pytest + +from tests.conftest import build_test_agent_loop, build_test_vibe_config +from tests.mock.utils import mock_llm_chunk +from tests.stubs.fake_backend import FakeBackend +from vibe.core import tracing +from vibe.core.config import OtelExporterConfig +from vibe.core.tools.base import BaseToolConfig, ToolPermission +from vibe.core.tracing import agent_span, setup_tracing, tool_span +from vibe.core.types import BaseEvent, FunctionCall, ToolCall + + +class _CollectingExporter(SpanExporter): + def __init__(self) -> None: + self.spans: list = [] + + def export(self, spans): + self.spans.extend(spans) + return SpanExportResult.SUCCESS + + def shutdown(self) -> None: + pass + + +@pytest.fixture(autouse=True) +def _otel_provider(monkeypatch: pytest.MonkeyPatch): + # Patch get_tracer_provider instead of set_tracer_provider to sidestep the + # OTEL singleton guard that rejects a second set_tracer_provider call. + exporter = _CollectingExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + monkeypatch.setattr(trace, "get_tracer_provider", lambda: provider) + yield exporter + + +class TestSetupTracing: + def test_noop_when_disabled(self) -> None: + config = MagicMock(enable_otel=False) + with patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set: + setup_tracing(config) + mock_set.assert_not_called() + + def test_noop_when_exporter_config_is_none(self) -> None: + config = MagicMock(enable_otel=True, otel_exporter_config=None) + with patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set: + setup_tracing(config) + mock_set.assert_not_called() + + def test_configures_provider_from_exporter_config(self) -> None: + config = MagicMock( + enable_otel=True, + otel_exporter_config=OtelExporterConfig( + endpoint="https://customer.mistral.ai/telemetry/v1/traces", + headers={"Authorization": "Bearer sk-test"}, + ), + ) + + with ( + patch( + "opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter" + ) as mock_exporter, + patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set, + ): + setup_tracing(config) + + mock_exporter.assert_called_once_with( + endpoint="https://customer.mistral.ai/telemetry/v1/traces", + headers={"Authorization": "Bearer sk-test"}, + ) + mock_set.assert_called_once() + assert isinstance(mock_set.call_args[0][0], TracerProvider) + + def test_custom_endpoint_has_no_auth_headers(self) -> None: + config = MagicMock( + enable_otel=True, + otel_exporter_config=OtelExporterConfig( + endpoint="https://my-collector:4318/v1/traces" + ), + ) + + with ( + patch( + "opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter" + ) as mock_exporter, + patch("vibe.core.tracing.trace.set_tracer_provider") as mock_set, + ): + setup_tracing(config) + + mock_exporter.assert_called_once_with( + endpoint="https://my-collector:4318/v1/traces", headers=None + ) + mock_set.assert_called_once() + assert isinstance(mock_set.call_args[0][0], TracerProvider) + + +class TestAgentSpan: + @pytest.mark.asyncio + async def test_span_name_status_and_attributes( + self, _otel_provider: _CollectingExporter + ) -> None: + async with agent_span(model="devstral", session_id="s1"): + pass + + assert len(_otel_provider.spans) == 1 + span = _otel_provider.spans[0] + assert span.name == "invoke_agent mistral-vibe" + assert span.status.status_code == StatusCode.OK + attrs = dict(span.attributes) + assert attrs["gen_ai.operation.name"] == "invoke_agent" + assert attrs["gen_ai.provider.name"] == "mistral_ai" + assert attrs["gen_ai.agent.name"] == "mistral-vibe" + assert attrs["gen_ai.request.model"] == "devstral" + assert attrs["gen_ai.conversation.id"] == "s1" + + @pytest.mark.asyncio + async def test_omits_optional_attributes( + self, _otel_provider: _CollectingExporter + ) -> None: + async with agent_span(): + pass + + attrs = dict(_otel_provider.spans[0].attributes) + assert "gen_ai.request.model" not in attrs + assert "gen_ai.conversation.id" not in attrs + + @pytest.mark.asyncio + async def test_records_error_on_exception( + self, _otel_provider: _CollectingExporter + ) -> None: + with pytest.raises(ValueError, match="boom"): + async with agent_span(): + raise ValueError("boom") + + span = _otel_provider.spans[0] + assert span.status.status_code == StatusCode.ERROR + assert "boom" in span.status.description + + +class TestToolSpan: + @pytest.mark.asyncio + async def test_span_name_status_and_attributes( + self, _otel_provider: _CollectingExporter + ) -> None: + async with tool_span(tool_name="bash", call_id="c1", arguments='{"cmd": "ls"}'): + pass + + assert len(_otel_provider.spans) == 1 + span = _otel_provider.spans[0] + assert span.name == "execute_tool bash" + assert span.status.status_code == StatusCode.OK + attrs = dict(span.attributes) + assert attrs["gen_ai.operation.name"] == "execute_tool" + assert attrs["gen_ai.tool.name"] == "bash" + assert attrs["gen_ai.tool.call.id"] == "c1" + assert attrs["gen_ai.tool.call.arguments"] == '{"cmd": "ls"}' + assert attrs["gen_ai.tool.type"] == "function" + + @pytest.mark.asyncio + async def test_records_error_and_exception_event( + self, _otel_provider: _CollectingExporter + ) -> None: + with pytest.raises(RuntimeError): + async with tool_span(tool_name="bash", call_id="c1", arguments="{}"): + raise RuntimeError("fail") + + span = _otel_provider.spans[0] + assert span.status.status_code == StatusCode.ERROR + exc_events = [e for e in span.events if e.name == "exception"] + assert len(exc_events) == 1 + + +class TestSpanHierarchy: + @pytest.mark.asyncio + async def test_chat_and_tool_are_siblings_under_agent( + self, _otel_provider: _CollectingExporter + ) -> None: + async with agent_span(model="devstral"): + tracer = trace.get_tracer("mistralai_sdk_tracer") + # Simulate a chat span created by the Mistral SDK. + with tracer.start_as_current_span("chat devstral"): + pass + + async with tool_span(tool_name="grep", call_id="c1", arguments="{}"): + pass + + with tracer.start_as_current_span("chat devstral"): + pass + + agent = next(s for s in _otel_provider.spans if "invoke_agent" in s.name) + children = [ + s + for s in _otel_provider.spans + if s.parent and s.parent.span_id == agent.context.span_id + ] + assert len(children) == 3 + assert [s.name for s in children] == [ + "chat devstral", + "execute_tool grep", + "chat devstral", + ] + + +class TestBaggagePropagation: + @pytest.mark.asyncio + async def test_tool_span_inherits_conversation_id( + self, _otel_provider: _CollectingExporter + ) -> None: + async with agent_span(model="devstral", session_id="sess-42"): + async with tool_span(tool_name="bash", call_id="c1", arguments="{}"): + pass + + tool = next(s for s in _otel_provider.spans if "execute_tool" in s.name) + assert dict(tool.attributes)["gen_ai.conversation.id"] == "sess-42" + + @pytest.mark.asyncio + async def test_tool_span_omits_conversation_id_when_no_session( + self, _otel_provider: _CollectingExporter + ) -> None: + async with agent_span(model="devstral"): + async with tool_span(tool_name="bash", call_id="c1", arguments="{}"): + pass + + tool = next(s for s in _otel_provider.spans if "execute_tool" in s.name) + assert "gen_ai.conversation.id" not in dict(tool.attributes) + + @pytest.mark.asyncio + async def test_baggage_does_not_leak_after_agent_span(self) -> None: + from opentelemetry import baggage as baggage_api + + async with agent_span(model="devstral", session_id="sess-1"): + pass + + assert baggage_api.get_baggage("gen_ai.conversation.id") is None + + +class TestErrorIsolation: + @pytest.mark.asyncio + async def test_yields_invalid_span_on_creation_failure( + self, _otel_provider: _CollectingExporter, monkeypatch: pytest.MonkeyPatch + ) -> None: + def _broken_tracer() -> trace.Tracer: + raise RuntimeError("tracer broken") + + monkeypatch.setattr(tracing, "_get_tracer", _broken_tracer) + + async with agent_span(): + pass + + assert len(_otel_provider.spans) == 0 + + @pytest.mark.asyncio + async def test_caller_exception_propagates_when_set_status_fails( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + def _broken_set_status(self, *args, **kwargs): + raise RuntimeError("set_status broken") + + monkeypatch.setattr( + "opentelemetry.sdk.trace.Span.set_status", _broken_set_status + ) + + with pytest.raises(ValueError, match="original"): + async with agent_span(): + raise ValueError("original") + + @pytest.mark.asyncio + async def test_cancellation_ends_span_without_error_status( + self, _otel_provider: _CollectingExporter + ) -> None: + with pytest.raises(asyncio.CancelledError): + async with agent_span(): + raise asyncio.CancelledError + + span = _otel_provider.spans[0] + assert span.status.status_code != StatusCode.ERROR + + @pytest.mark.asyncio + async def test_success_path_swallows_span_end_failure( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + def _broken_end(self, *args, **kwargs): + raise RuntimeError("end broken") + + monkeypatch.setattr("opentelemetry.sdk.trace.Span.end", _broken_end) + + async with agent_span(): + pass + + +class TestIntegration: + @staticmethod + async def _collect_events(agent_loop, prompt: str) -> list[BaseEvent]: + return [ev async for ev in agent_loop.act(prompt)] + + @pytest.mark.asyncio + async def test_agent_turn_with_tool_call_produces_spans( + self, _otel_provider: _CollectingExporter + ) -> None: + tool_call = ToolCall( + id="call_1", + index=0, + function=FunctionCall(name="todo", arguments='{"action": "read"}'), + ) + backend = FakeBackend([ + [mock_llm_chunk(content="Let me check.", tool_calls=[tool_call])], + [mock_llm_chunk(content="Done.")], + ]) + config = build_test_vibe_config( + enabled_tools=["todo"], + tools={"todo": BaseToolConfig(permission=ToolPermission.ALWAYS)}, + system_prompt_id="tests", + include_project_context=False, + include_prompt_detail=False, + ) + agent_loop = build_test_agent_loop(config=config, backend=backend) + + await self._collect_events(agent_loop, "What are my todos?") + + spans = _otel_provider.spans + agent_spans = [s for s in spans if "invoke_agent" in s.name] + tool_spans = [s for s in spans if "execute_tool" in s.name] + + assert len(agent_spans) == 1 + assert len(tool_spans) == 1 + + agent = agent_spans[0] + tool = tool_spans[0] + + # Parent-child relationship + assert tool.parent is not None + assert tool.parent.span_id == agent.context.span_id + + # -- Agent span: name, status, and every attribute set by agent_span() -- + assert agent.name == "invoke_agent mistral-vibe" + assert agent.status.status_code == StatusCode.OK + agent_attrs = dict(agent.attributes) + assert agent_attrs["gen_ai.operation.name"] == "invoke_agent" + assert agent_attrs["gen_ai.provider.name"] == "mistral_ai" + assert agent_attrs["gen_ai.agent.name"] == "mistral-vibe" + assert agent_attrs["gen_ai.request.model"] == "mistral-vibe-cli-latest" + assert agent_attrs["gen_ai.conversation.id"] == agent_loop.session_id + + # -- Tool span: name, status, and every attribute set by tool_span() + set_tool_result() -- + assert tool.name == "execute_tool todo" + assert tool.status.status_code == StatusCode.OK + tool_attrs = dict(tool.attributes) + assert tool_attrs["gen_ai.operation.name"] == "execute_tool" + assert tool_attrs["gen_ai.tool.name"] == "todo" + assert tool_attrs["gen_ai.tool.call.id"] == "call_1" + assert tool_attrs["gen_ai.tool.type"] == "function" + assert ( + tool_attrs["gen_ai.tool.call.arguments"] == '{"action":"read","todos":null}' + ) + assert tool_attrs["gen_ai.tool.call.result"] == ( + "message: Retrieved 0 todos\ntodos: []\ntotal_count: 0" + ) + # Conversation ID propagated via baggage from agent_span + assert tool_attrs["gen_ai.conversation.id"] == agent_loop.session_id diff --git a/tests/test_turn_summary.py b/tests/test_turn_summary.py new file mode 100644 index 0000000..0184f39 --- /dev/null +++ b/tests/test_turn_summary.py @@ -0,0 +1,349 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import logging + +import pytest + +from tests.mock.utils import mock_llm_chunk +from tests.stubs.fake_backend import FakeBackend +from vibe.cli.turn_summary import ( + NARRATOR_MODEL, + NoopTurnSummary, + TurnSummaryResult, + TurnSummaryTracker, + create_narrator_backend, +) +from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig +from vibe.core.llm.backend.mistral import MistralBackend +from vibe.core.types import AssistantEvent, Backend, ToolStreamEvent, UserMessageEvent + +_TEST_MODEL = ModelConfig(name="test-model", provider="test", alias="test-model") + + +def _noop_callback(result: TurnSummaryResult) -> None: + pass + + +class TestCreateNarratorBackend: + def test_uses_mistral_provider(self, monkeypatch): + monkeypatch.setenv("MISTRAL_API_KEY", "test-key") + config = VibeConfig() + result = create_narrator_backend(config) + assert result is not None + backend, model = result + assert isinstance(backend, MistralBackend) + assert model is NARRATOR_MODEL + + def test_uses_custom_provider_base_url(self, monkeypatch): + monkeypatch.setenv("MISTRAL_API_KEY", "test-key") + custom_provider = ProviderConfig( + name="mistral", + api_base="https://on-prem.example.com/v1", + api_key_env_var="MISTRAL_API_KEY", + backend=Backend.MISTRAL, + ) + config = VibeConfig(providers=[custom_provider]) + result = create_narrator_backend(config) + assert result is not None + backend, model = result + assert isinstance(backend, MistralBackend) + assert backend._provider.api_base == custom_provider.api_base + + def test_returns_none_when_api_key_missing(self, monkeypatch): + monkeypatch.setenv("MISTRAL_API_KEY", "test-key") + config = VibeConfig() + monkeypatch.delenv("MISTRAL_API_KEY") + assert create_narrator_backend(config) is None + + def test_returns_none_when_provider_missing(self): + config = VibeConfig(providers=[]) + assert create_narrator_backend(config) is None + + +class TestTrack: + def _make_tracker(self, backend: FakeBackend | None = None) -> TurnSummaryTracker: + return TurnSummaryTracker( + backend=backend or FakeBackend(), + model=_TEST_MODEL, + on_summary=_noop_callback, + ) + + def test_assistant_event(self): + tracker = self._make_tracker() + tracker.start_turn("test") + tracker.track(AssistantEvent(content="chunk1")) + tracker.track(AssistantEvent(content="chunk2")) + assert tracker._data is not None + assert tracker._data.assistant_fragments == ["chunk1", "chunk2"] + + def test_assistant_event_empty_content_ignored(self): + tracker = self._make_tracker() + tracker.start_turn("test") + tracker.track(AssistantEvent(content="")) + assert tracker._data is not None + assert tracker._data.assistant_fragments == [] + + def test_start_turn_preserves_full_message(self): + tracker = self._make_tracker() + long_msg = "a" * 1500 + tracker.start_turn(long_msg) + assert tracker._data is not None + assert len(tracker._data.user_message) == 1500 + + def test_start_turn_increments_generation(self): + tracker = self._make_tracker() + assert tracker.generation == 0 + tracker.start_turn("turn 1") + assert tracker.generation == 1 + tracker.start_turn("turn 2") + assert tracker.generation == 2 + + def test_cancel_turn_clears_data(self): + tracker = self._make_tracker() + tracker.start_turn("test") + assert tracker._data is not None + tracker.cancel_turn() + assert tracker._data is None + + def test_set_error_stores_message(self): + tracker = self._make_tracker() + tracker.start_turn("test") + tracker.set_error("rate limit exceeded") + assert tracker._data is not None + assert tracker._data.error == "rate limit exceeded" + + def test_set_error_without_start_is_noop(self): + tracker = self._make_tracker() + tracker.set_error("should be ignored") + assert tracker._data is None + + def test_cancel_turn_without_start_is_noop(self): + tracker = self._make_tracker() + tracker.cancel_turn() + assert tracker._data is None + + def test_unrelated_events_ignored(self): + tracker = self._make_tracker() + tracker.start_turn("test") + tracker.track(UserMessageEvent(content="hi", message_id="m1")) + tracker.track( + ToolStreamEvent(tool_name="bash", message="output", tool_call_id="tc1") + ) + assert tracker._data is not None + assert tracker._data.assistant_fragments == [] + + +class TestTurnSummaryTracker: + def _make_tracker( + self, + backend: FakeBackend, + on_summary: Callable[[TurnSummaryResult], None] = _noop_callback, + ) -> TurnSummaryTracker: + return TurnSummaryTracker( + backend=backend, model=_TEST_MODEL, on_summary=on_summary + ) + + @pytest.mark.asyncio + async def test_track_accumulates_events(self): + backend = FakeBackend(mock_llm_chunk(content="summary")) + tracker = self._make_tracker(backend) + tracker.start_turn("hello") + tracker.track(AssistantEvent(content="chunk1")) + tracker.track(AssistantEvent(content="chunk2")) + assert tracker._data is not None + assert tracker._data.assistant_fragments == ["chunk1", "chunk2"] + + @pytest.mark.asyncio + async def test_end_turn_fires_summary(self): + backend = FakeBackend(mock_llm_chunk(content="the summary")) + tracker = self._make_tracker(backend) + + tracker.start_turn("do something") + tracker.track(AssistantEvent(content="response")) + tracker.end_turn() + await asyncio.sleep(0.1) + + assert len(backend.requests_messages) == 1 + summary_msgs = backend.requests_messages[0] + assert len(summary_msgs) == 2 + assert summary_msgs[0].role.value == "system" + assert summary_msgs[1].role.value == "user" + assert summary_msgs[1].content is not None + assert "do something" in summary_msgs[1].content + + @pytest.mark.asyncio + async def test_end_turn_clears_state(self): + backend = FakeBackend(mock_llm_chunk(content="summary")) + tracker = self._make_tracker(backend) + + tracker.start_turn("hello") + tracker.end_turn() + assert tracker._data is None + + @pytest.mark.asyncio + async def test_track_without_start_is_noop(self): + backend = FakeBackend(mock_llm_chunk(content="summary")) + tracker = self._make_tracker(backend) + tracker.track(AssistantEvent(content="ignored")) + assert tracker._data is None + + @pytest.mark.asyncio + async def test_end_turn_without_start_is_noop(self): + backend = FakeBackend(mock_llm_chunk(content="summary")) + tracker = self._make_tracker(backend) + tracker.end_turn() + assert len(backend.requests_messages) == 0 + + @pytest.mark.asyncio + async def test_end_turn_after_cancel_is_noop(self): + backend = FakeBackend(mock_llm_chunk(content="summary")) + tracker = self._make_tracker(backend) + tracker.start_turn("hello") + tracker.cancel_turn() + tracker.end_turn() + await asyncio.sleep(0.1) + assert len(backend.requests_messages) == 0 + + @pytest.mark.asyncio + async def test_on_summary_callback_called(self): + backend = FakeBackend(mock_llm_chunk(content="the summary text")) + received: list[TurnSummaryResult] = [] + + def capture(result: TurnSummaryResult) -> None: + received.append(result) + + tracker = self._make_tracker(backend, on_summary=capture) + tracker.start_turn("hello") + tracker.track(AssistantEvent(content="response")) + tracker.end_turn() + await asyncio.sleep(0.1) + + assert len(received) == 1 + assert received[0].summary == "the summary text" + assert received[0].generation == tracker.generation + + @pytest.mark.asyncio + async def test_backend_error_calls_callback_with_none(self): + backend = FakeBackend(exception_to_raise=RuntimeError("backend down")) + received: list[TurnSummaryResult] = [] + + def capture(result: TurnSummaryResult) -> None: + received.append(result) + + tracker = self._make_tracker(backend, on_summary=capture) + tracker.start_turn("hello") + tracker.end_turn() + await asyncio.sleep(0.2) + + assert len(received) == 1 + assert received[0].summary is None + + @pytest.mark.asyncio + async def test_backend_error_logged_no_crash(self, caplog): + backend = FakeBackend(exception_to_raise=RuntimeError("backend down")) + tracker = self._make_tracker(backend) + + with caplog.at_level(logging.WARNING, logger="vibe"): + tracker.start_turn("hello") + tracker.end_turn() + await asyncio.sleep(0.2) + + assert "Turn summary generation failed" in caplog.text + + @pytest.mark.asyncio + async def test_close_cancels_pending_tasks(self): + backend = FakeBackend(mock_llm_chunk(content="summary")) + tracker = self._make_tracker(backend) + + tracker.start_turn("hello") + tracker.end_turn() + assert len(tracker._tasks) == 1 + + await tracker.close() + assert len(tracker._tasks) == 0 + + @pytest.mark.asyncio + async def test_error_only_turn_includes_error_in_summary(self): + backend = FakeBackend(mock_llm_chunk(content="error summary")) + received: list[TurnSummaryResult] = [] + + def capture(result: TurnSummaryResult) -> None: + received.append(result) + + tracker = self._make_tracker(backend, on_summary=capture) + tracker.start_turn("do something") + tracker.set_error("Rate limit exceeded") + cancel = tracker.end_turn() + await asyncio.sleep(0.1) + + assert cancel is not None + assert len(backend.requests_messages) == 1 + prompt_content = backend.requests_messages[0][1].content + assert prompt_content is not None + assert "do something" in prompt_content + assert "## Error" in prompt_content + assert "Rate limit exceeded" in prompt_content + assert "## Assistant Response" not in prompt_content + assert len(received) == 1 + assert received[0].summary == "error summary" + + @pytest.mark.asyncio + async def test_error_with_partial_response_includes_both(self): + backend = FakeBackend(mock_llm_chunk(content="partial error summary")) + tracker = self._make_tracker(backend) + tracker.start_turn("do something") + tracker.track(AssistantEvent(content="partial response")) + tracker.set_error("connection lost") + tracker.end_turn() + await asyncio.sleep(0.1) + + assert len(backend.requests_messages) == 1 + prompt_content = backend.requests_messages[0][1].content + assert prompt_content is not None + assert "## Assistant Response" in prompt_content + assert "partial response" in prompt_content + assert "## Error" in prompt_content + assert "connection lost" in prompt_content + + @pytest.mark.asyncio + async def test_stale_summary_has_old_generation(self): + backend = FakeBackend(mock_llm_chunk(content="stale summary")) + received: list[TurnSummaryResult] = [] + + def capture(result: TurnSummaryResult) -> None: + received.append(result) + + tracker = self._make_tracker(backend, on_summary=capture) + + tracker.start_turn("turn 1") + tracker.end_turn() + + tracker.start_turn("turn 2") + + await asyncio.sleep(0.1) + + assert len(received) == 1 + assert received[0].generation == 1 + assert tracker.generation == 2 + assert received[0].generation != tracker.generation + + +class TestNoopTurnSummary: + def test_all_methods_callable(self): + noop = NoopTurnSummary() + noop.start_turn("hello") + noop.track(AssistantEvent(content="chunk")) + noop.set_error("some error") + noop.cancel_turn() + noop.end_turn() + + def test_generation_is_zero(self): + noop = NoopTurnSummary() + assert noop.generation == 0 + + @pytest.mark.asyncio + async def test_close_is_noop(self): + noop = NoopTurnSummary() + await noop.close() diff --git a/tests/tools/test_arity.py b/tests/tools/test_arity.py new file mode 100644 index 0000000..4237407 --- /dev/null +++ b/tests/tools/test_arity.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from vibe.core.tools.arity import build_session_pattern + + +class TestBuildSessionPattern: + def test_single_token_in_arity(self): + assert build_session_pattern(["mkdir", "foo"]) == "mkdir *" + + def test_single_token_not_in_arity(self): + assert build_session_pattern(["whoami"]) == "whoami *" + + def test_two_token_arity(self): + assert build_session_pattern(["git", "checkout", "main"]) == "git checkout *" + + def test_three_token_arity(self): + assert build_session_pattern(["npm", "run", "dev"]) == "npm run dev *" + + def test_longer_prefix_wins(self): + # "git" is arity 2, but "git stash" is arity 3 + assert build_session_pattern(["git", "stash", "pop"]) == "git stash pop *" + + def test_docker_compose(self): + assert ( + build_session_pattern(["docker", "compose", "up", "-d"]) + == "docker compose up *" + ) + + def test_empty_tokens(self): + assert build_session_pattern([]) == "" + + def test_unknown_command_returns_first_token(self): + assert build_session_pattern(["mycommand", "arg1", "arg2"]) == "mycommand *" + + def test_cat_is_arity_1(self): + assert build_session_pattern(["cat", "file.txt"]) == "cat *" + + def test_rm_is_arity_1(self): + assert build_session_pattern(["rm", "-rf", "dir"]) == "rm *" + + def test_uv_run(self): + assert build_session_pattern(["uv", "run", "pytest"]) == "uv run pytest *" + + def test_pip_install(self): + assert build_session_pattern(["pip", "install", "numpy"]) == "pip install *" + + def test_git_remote_add(self): + assert ( + build_session_pattern(["git", "remote", "add", "origin", "url"]) + == "git remote add *" + ) + + def test_gh_pr_list(self): + assert build_session_pattern(["gh", "pr", "list"]) == "gh pr list *" diff --git a/tests/tools/test_bash.py b/tests/tools/test_bash.py index de316a5..c27e9d8 100644 --- a/tests/tools/test_bash.py +++ b/tests/tools/test_bash.py @@ -5,6 +5,7 @@ import pytest from tests.mock.utils import collect_result from vibe.core.tools.base import BaseToolState, ToolError, ToolPermission from vibe.core.tools.builtins.bash import Bash, BashArgs, BashToolConfig +from vibe.core.tools.permissions import PermissionContext @pytest.fixture @@ -80,7 +81,10 @@ def test_find_not_in_default_allowlist(): bash_tool = Bash(config=BashToolConfig(), state=BaseToolState()) # find -exec runs arbitrary commands; must not be allowlisted by default permission = bash_tool.resolve_permission(BashArgs(command="find . -exec id \\;")) - assert permission is not ToolPermission.ALWAYS + assert ( + not isinstance(permission, PermissionContext) + or permission.permission is not ToolPermission.ALWAYS + ) def test_resolve_permission(): @@ -92,9 +96,13 @@ def test_resolve_permission(): mixed = bash_tool.resolve_permission(BashArgs(command="pwd && whoami")) empty = bash_tool.resolve_permission(BashArgs(command="")) - assert allowlisted is ToolPermission.ALWAYS - assert denylisted is ToolPermission.NEVER - assert mixed is None + assert isinstance(allowlisted, PermissionContext) + assert allowlisted.permission is ToolPermission.ALWAYS + assert isinstance(denylisted, PermissionContext) + assert denylisted.permission is ToolPermission.NEVER + assert isinstance(mixed, PermissionContext) + assert mixed.permission is ToolPermission.ASK + assert any(rp.label == "whoami *" for rp in mixed.required_permissions) assert empty is None @@ -108,80 +116,95 @@ class TestResolvePermissionWindowsSyntax: def test_dir_with_windows_flags_allowlisted(self): bash_tool = self._make_bash(allowlist=["dir"]) result = bash_tool.resolve_permission(BashArgs(command="dir /s /b")) - assert result is ToolPermission.ALWAYS + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ALWAYS def test_type_command_allowlisted(self): bash_tool = self._make_bash(allowlist=["type"]) result = bash_tool.resolve_permission(BashArgs(command="type file.txt")) - assert result is ToolPermission.ALWAYS + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ALWAYS def test_findstr_allowlisted(self): bash_tool = self._make_bash(allowlist=["findstr"]) result = bash_tool.resolve_permission( BashArgs(command="findstr /s pattern *.txt") ) - assert result is ToolPermission.ALWAYS + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ALWAYS def test_ver_allowlisted(self): bash_tool = self._make_bash(allowlist=["ver"]) result = bash_tool.resolve_permission(BashArgs(command="ver")) - assert result is ToolPermission.ALWAYS + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ALWAYS def test_where_allowlisted(self): bash_tool = self._make_bash(allowlist=["where"]) result = bash_tool.resolve_permission(BashArgs(command="where python")) - assert result is ToolPermission.ALWAYS + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ALWAYS def test_cmd_k_denylisted(self): bash_tool = self._make_bash(denylist=["cmd /k"]) result = bash_tool.resolve_permission(BashArgs(command="cmd /k something")) - assert result is ToolPermission.NEVER + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.NEVER def test_powershell_noexit_denylisted(self): bash_tool = self._make_bash(denylist=["powershell -NoExit"]) result = bash_tool.resolve_permission(BashArgs(command="powershell -NoExit")) - assert result is ToolPermission.NEVER + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.NEVER def test_notepad_denylisted(self): bash_tool = self._make_bash(denylist=["notepad"]) result = bash_tool.resolve_permission(BashArgs(command="notepad file.txt")) - assert result is ToolPermission.NEVER + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.NEVER def test_cmd_standalone_denylisted(self): bash_tool = self._make_bash(denylist_standalone=["cmd"]) result = bash_tool.resolve_permission(BashArgs(command="cmd")) - assert result is ToolPermission.NEVER + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.NEVER def test_powershell_standalone_denylisted(self): bash_tool = self._make_bash(denylist_standalone=["powershell"]) result = bash_tool.resolve_permission(BashArgs(command="powershell")) - assert result is ToolPermission.NEVER + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.NEVER def test_powershell_cmdlet_asks(self): bash_tool = self._make_bash(allowlist=["dir", "echo"]) result = bash_tool.resolve_permission(BashArgs(command="Get-ChildItem -Path .")) - assert result is None + assert isinstance(result, PermissionContext) + assert result.permission == ToolPermission.ASK def test_mixed_allowed_and_unknown_asks(self): bash_tool = self._make_bash(allowlist=["git status"]) result = bash_tool.resolve_permission( BashArgs(command="git status && npm install") ) - assert result is None + assert isinstance(result, PermissionContext) + assert result.permission == ToolPermission.ASK def test_chained_windows_commands_all_allowed(self): bash_tool = self._make_bash(allowlist=["dir", "echo"]) result = bash_tool.resolve_permission(BashArgs(command="dir /s && echo done")) - assert result is ToolPermission.ALWAYS + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ALWAYS def test_chained_commands_one_denied(self): bash_tool = self._make_bash(allowlist=["dir"], denylist=["rm"]) result = bash_tool.resolve_permission(BashArgs(command="dir /s && rm -rf /")) - assert result is ToolPermission.NEVER + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.NEVER def test_piped_windows_commands(self): bash_tool = self._make_bash(allowlist=["findstr", "type"]) result = bash_tool.resolve_permission( BashArgs(command="type file.txt | findstr pattern") ) - assert result is ToolPermission.ALWAYS + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ALWAYS diff --git a/tests/tools/test_granular_permissions.py b/tests/tools/test_granular_permissions.py new file mode 100644 index 0000000..288b4c1 --- /dev/null +++ b/tests/tools/test_granular_permissions.py @@ -0,0 +1,750 @@ +from __future__ import annotations + +import os + +import pytest + +from vibe.core.tools.base import BaseToolState, ToolPermission +from vibe.core.tools.builtins.bash import ( + Bash, + BashArgs, + BashToolConfig, + _collect_outside_dirs, +) +from vibe.core.tools.builtins.grep import Grep, GrepArgs, GrepToolConfig +from vibe.core.tools.builtins.read_file import ( + ReadFile, + ReadFileArgs, + ReadFileState, + ReadFileToolConfig, +) +from vibe.core.tools.builtins.search_replace import ( + SearchReplace, + SearchReplaceArgs, + SearchReplaceConfig, +) +from vibe.core.tools.builtins.webfetch import WebFetch, WebFetchArgs, WebFetchConfig +from vibe.core.tools.builtins.write_file import ( + WriteFile, + WriteFileArgs, + WriteFileConfig, +) +from vibe.core.tools.permissions import ( + ApprovedRule, + PermissionContext, + PermissionScope, + RequiredPermission, +) +from vibe.core.tools.utils import wildcard_match + + +class TestBashGranularPermissions: + @pytest.fixture(autouse=True) + def _setup(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + self.workdir = tmp_path + + def _bash(self, **kwargs): + config = BashToolConfig(**kwargs) + return Bash(config=config, state=BaseToolState()) + + def test_allowlisted_command_always(self): + bash = self._bash() + result = bash.resolve_permission(BashArgs(command="git status")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ALWAYS + + def test_denylisted_command_never(self): + bash = self._bash() + result = bash.resolve_permission(BashArgs(command="vim file.txt")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.NEVER + + def test_standalone_denylisted_never(self): + bash = self._bash() + result = bash.resolve_permission(BashArgs(command="python")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.NEVER + + def test_standalone_denylisted_with_args_not_denied(self): + bash = self._bash() + result = bash.resolve_permission(BashArgs(command="python script.py")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ASK + + def test_unknown_command_returns_permission_context(self): + bash = self._bash() + result = bash.resolve_permission(BashArgs(command="npm install")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ASK + assert len(result.required_permissions) == 1 + rp = result.required_permissions[0] + assert rp.scope is PermissionScope.COMMAND_PATTERN + assert rp.session_pattern == "npm install *" + + def test_arity_based_prefix(self): + bash = self._bash() + result = bash.resolve_permission(BashArgs(command="docker compose up -d")) + assert isinstance(result, PermissionContext) + rp = result.required_permissions[0] + assert rp.session_pattern == "docker compose up *" + + def test_multiple_commands_dedup(self): + bash = self._bash() + result = bash.resolve_permission( + BashArgs(command="npm install foo && npm install bar") + ) + assert isinstance(result, PermissionContext) + command_labels = [ + rp.label + for rp in result.required_permissions + if rp.scope is PermissionScope.COMMAND_PATTERN + ] + assert command_labels == ["npm install *"] + + def test_cd_excluded_from_command_patterns(self): + bash = self._bash() + result = bash.resolve_permission(BashArgs(command="cd /tmp")) + assert isinstance(result, PermissionContext) + assert all( + rp.scope is not PermissionScope.COMMAND_PATTERN + for rp in result.required_permissions + ) + + def test_outside_directory_detection(self): + bash = self._bash() + result = bash.resolve_permission(BashArgs(command="mkdir /tmp/test")) + assert isinstance(result, PermissionContext) + outside = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.OUTSIDE_DIRECTORY + ] + assert len(outside) >= 1 + + def test_outside_directory_has_glob_pattern(self): + bash = self._bash() + result = bash.resolve_permission(BashArgs(command="mkdir /tmp/test")) + assert isinstance(result, PermissionContext) + outside = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.OUTSIDE_DIRECTORY + ] + assert any("/tmp" in rp.session_pattern for rp in outside) + + def test_in_workdir_no_outside_directory(self): + bash = self._bash() + (self.workdir / "subdir").mkdir() + result = bash.resolve_permission(BashArgs(command="mkdir subdir/child")) + assert isinstance(result, PermissionContext) + outside = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.OUTSIDE_DIRECTORY + ] + assert len(outside) == 0 + + def test_rm_uses_arity_based_pattern(self): + bash = self._bash() + result = bash.resolve_permission(BashArgs(command="rm -rf /tmp/something")) + assert isinstance(result, PermissionContext) + cmd_perms = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.COMMAND_PATTERN + ] + assert len(cmd_perms) == 1 + assert cmd_perms[0].session_pattern == "rm *" + + def test_sensitive_sudo_exact_pattern(self): + bash = self._bash() + result = bash.resolve_permission(BashArgs(command="sudo apt install foo")) + assert isinstance(result, PermissionContext) + cmd_perms = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.COMMAND_PATTERN + ] + assert cmd_perms[0].session_pattern == "sudo apt install foo" + + def test_rmdir_uses_arity_based_pattern(self): + bash = self._bash() + result = bash.resolve_permission(BashArgs(command="rmdir foo")) + assert isinstance(result, PermissionContext) + cmd_perms = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.COMMAND_PATTERN + ] + assert cmd_perms[0].session_pattern == "rmdir *" + + def test_sensitive_bypasses_allowlist(self): + bash = self._bash(allowlist=["sudo"]) + result = bash.resolve_permission(BashArgs(command="sudo ls")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ASK + + def test_allowlisted_outside_dir_still_asks(self): + bash = self._bash() + # cat is allowlisted but /etc/passwd is outside workdir + result = bash.resolve_permission(BashArgs(command="cat /etc/passwd")) + assert isinstance(result, PermissionContext) + outside = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.OUTSIDE_DIRECTORY + ] + assert len(outside) == 1 + + def test_allowlisted_relative_traversal_outside_dir_still_asks(self): + bash = self._bash() + (self.workdir / "src").mkdir() + result = bash.resolve_permission( + BashArgs(command="cat src/../../../etc/passwd") + ) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ASK + outside = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.OUTSIDE_DIRECTORY + ] + assert len(outside) >= 1 + + def test_allowlisted_in_workdir_subdir_auto_approves(self): + bash = self._bash() + (self.workdir / "foo").mkdir() + (self.workdir / "foo" / "bar.txt").touch() + result = bash.resolve_permission(BashArgs(command="cat foo/bar.txt")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ALWAYS + + def test_allowlisted_in_workdir_auto_approves(self): + bash = self._bash() + result = bash.resolve_permission(BashArgs(command="cat README.md")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ALWAYS + + def test_mixed_allowlisted_and_not(self): + bash = self._bash() + result = bash.resolve_permission( + BashArgs(command="echo hello && npm install foo") + ) + assert isinstance(result, PermissionContext) + cmd_perms = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.COMMAND_PATTERN + ] + assert len(cmd_perms) == 1 + assert cmd_perms[0].session_pattern == "npm install *" + + def test_empty_command_returns_none(self): + bash = self._bash() + assert bash.resolve_permission(BashArgs(command="")) is None + + def test_chmod_plus_skipped_as_flag(self): + bash = self._bash() + result = bash.resolve_permission(BashArgs(command="chmod +x /tmp/script.sh")) + assert isinstance(result, PermissionContext) + outside = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.OUTSIDE_DIRECTORY + ] + assert len(outside) >= 1 + + +class TestReadFileGranularPermissions: + @pytest.fixture(autouse=True) + def _setup(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + self.workdir = tmp_path + + def _read_file(self, **kwargs): + config = ReadFileToolConfig(**kwargs) + return ReadFile(config=config, state=ReadFileState()) + + def test_in_workdir_normal_file_returns_none(self): + (self.workdir / "test.py").touch() + tool = self._read_file() + assert tool.resolve_permission(ReadFileArgs(path="test.py")) is None + + def test_outside_workdir_returns_permission_context(self): + tool = self._read_file() + result = tool.resolve_permission(ReadFileArgs(path="/tmp/file.txt")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ASK + outside = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.OUTSIDE_DIRECTORY + ] + assert len(outside) == 1 + + def test_sensitive_env_file_returns_permission_context(self): + (self.workdir / ".env").touch() + tool = self._read_file() + result = tool.resolve_permission(ReadFileArgs(path=".env")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ASK + sensitive = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.FILE_PATTERN + ] + assert len(sensitive) == 1 + assert sensitive[0].label.startswith("accessing sensitive files") + + def test_sensitive_env_local_file(self): + (self.workdir / ".env.local").touch() + tool = self._read_file() + result = tool.resolve_permission(ReadFileArgs(path=".env.local")) + assert isinstance(result, PermissionContext) + sensitive = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.FILE_PATTERN + ] + assert len(sensitive) == 1 + + def test_sensitive_outside_both_permissions(self): + tool = self._read_file() + result = tool.resolve_permission(ReadFileArgs(path="/tmp/.env")) + assert isinstance(result, PermissionContext) + scopes = {rp.scope for rp in result.required_permissions} + assert PermissionScope.FILE_PATTERN in scopes + assert PermissionScope.OUTSIDE_DIRECTORY in scopes + + def test_denylisted_returns_never(self): + tool = self._read_file(denylist=["*/secret*"]) + result = tool.resolve_permission(ReadFileArgs(path="secret.key")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.NEVER + + def test_allowlisted_returns_always(self): + tool = self._read_file(allowlist=["*/README*"]) + result = tool.resolve_permission( + ReadFileArgs(path=str(self.workdir / "README.md")) + ) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ALWAYS + + def test_custom_sensitive_patterns(self): + (self.workdir / "credentials.json").touch() + tool = self._read_file(sensitive_patterns=["*/credentials*"]) + result = tool.resolve_permission(ReadFileArgs(path="credentials.json")) + assert isinstance(result, PermissionContext) + + +class TestWriteFileGranularPermissions: + @pytest.fixture(autouse=True) + def _setup(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + self.workdir = tmp_path + + def _write_file(self): + config = WriteFileConfig() + return WriteFile(config=config, state=BaseToolState()) + + def test_in_workdir_returns_none(self): + tool = self._write_file() + assert ( + tool.resolve_permission(WriteFileArgs(path="test.py", content="x")) is None + ) + + def test_outside_workdir_returns_permission_context(self): + tool = self._write_file() + result = tool.resolve_permission( + WriteFileArgs(path="/tmp/file.txt", content="x") + ) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ASK + + def test_sensitive_env_file_asks(self): + (self.workdir / ".env").touch() + tool = self._write_file() + result = tool.resolve_permission( + WriteFileArgs(path=".env", content="x", overwrite=True) + ) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ASK + + +class TestSearchReplaceGranularPermissions: + @pytest.fixture(autouse=True) + def _setup(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + def test_outside_workdir_returns_permission_context(self): + config = SearchReplaceConfig() + tool = SearchReplace(config=config, state=BaseToolState()) + result = tool.resolve_permission( + SearchReplaceArgs(file_path="/tmp/file.py", content="x") + ) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ASK + + +class TestGrepGranularPermissions: + @pytest.fixture(autouse=True) + def _setup(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + self.workdir = tmp_path + + def _grep(self): + config = GrepToolConfig() + return Grep(config=config, state=BaseToolState()) + + def test_in_workdir_normal_path_returns_none(self): + tool = self._grep() + assert tool.resolve_permission(GrepArgs(pattern="foo", path=".")) is None + + def test_outside_workdir_returns_permission_context(self): + tool = self._grep() + result = tool.resolve_permission(GrepArgs(pattern="foo", path="/tmp")) + assert isinstance(result, PermissionContext) + + def test_sensitive_env_directory(self): + (self.workdir / ".env").touch() + tool = self._grep() + result = tool.resolve_permission(GrepArgs(pattern="foo", path=".env")) + assert isinstance(result, PermissionContext) + sensitive = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.FILE_PATTERN + ] + assert len(sensitive) == 1 + + +class TestApprovalFlowSimulation: + @pytest.fixture(autouse=True) + def _setup(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + def _is_covered( + self, tool_name: str, rp: RequiredPermission, rules: list[ApprovedRule] + ) -> bool: + return any( + rule.tool_name == tool_name + and rule.scope == rp.scope + and wildcard_match(rp.invocation_pattern, rule.session_pattern) + for rule in rules + ) + + def test_mkdir_approved_covers_subsequent_mkdir(self): + rules = [ + ApprovedRule( + tool_name="bash", + scope=PermissionScope.COMMAND_PATTERN, + session_pattern="mkdir *", + ) + ] + bash = Bash(config=BashToolConfig(), state=BaseToolState()) + result = bash.resolve_permission(BashArgs(command="mkdir another_dir")) + assert isinstance(result, PermissionContext) + uncovered = [ + rp + for rp in result.required_permissions + if not self._is_covered("bash", rp, rules) + ] + assert not any(rp.scope is PermissionScope.COMMAND_PATTERN for rp in uncovered) + + def test_mkdir_approved_does_not_cover_npm(self): + rules = [ + ApprovedRule( + tool_name="bash", + scope=PermissionScope.COMMAND_PATTERN, + session_pattern="mkdir *", + ) + ] + bash = Bash(config=BashToolConfig(), state=BaseToolState()) + result = bash.resolve_permission(BashArgs(command="npm install")) + assert isinstance(result, PermissionContext) + uncovered = [ + rp + for rp in result.required_permissions + if not self._is_covered("bash", rp, rules) + ] + assert len(uncovered) == 1 + assert uncovered[0].session_pattern == "npm install *" + + def test_outside_dir_approved_covers_subsequent(self): + bash = Bash(config=BashToolConfig(), state=BaseToolState()) + result = bash.resolve_permission(BashArgs(command="mkdir /tmp/newdir")) + assert isinstance(result, PermissionContext) + outside_rps = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.OUTSIDE_DIRECTORY + ] + assert len(outside_rps) == 1 + # Resolved pattern may differ per OS (e.g. /private/tmp/* on macOS) + rules = [ + ApprovedRule( + tool_name="bash", + scope=PermissionScope.OUTSIDE_DIRECTORY, + session_pattern=outside_rps[0].session_pattern, + ), + ApprovedRule( + tool_name="bash", + scope=PermissionScope.COMMAND_PATTERN, + session_pattern="mkdir *", + ), + ] + uncovered = [ + rp + for rp in result.required_permissions + if not self._is_covered("bash", rp, rules) + ] + assert len(uncovered) == 0 + + def test_rm_approved_covers_subsequent_rm(self): + rules = [ + ApprovedRule( + tool_name="bash", + scope=PermissionScope.COMMAND_PATTERN, + session_pattern="rm *", + ) + ] + bash = Bash(config=BashToolConfig(), state=BaseToolState()) + result = bash.resolve_permission(BashArgs(command="rm -rf /tmp/something")) + assert isinstance(result, PermissionContext) + cmd_perms = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.COMMAND_PATTERN + ] + assert cmd_perms[0].session_pattern == "rm *" + uncovered = [rp for rp in cmd_perms if not self._is_covered("bash", rp, rules)] + assert len(uncovered) == 0 + + def test_sudo_exact_approval_doesnt_cover_different_invocation(self): + rules = [ + ApprovedRule( + tool_name="bash", + scope=PermissionScope.COMMAND_PATTERN, + session_pattern="sudo apt install foo", + ) + ] + bash = Bash(config=BashToolConfig(), state=BaseToolState()) + result = bash.resolve_permission(BashArgs(command="sudo apt install bar")) + assert isinstance(result, PermissionContext) + cmd_perms = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.COMMAND_PATTERN + ] + uncovered = [rp for rp in cmd_perms if not self._is_covered("bash", rp, rules)] + assert len(uncovered) == 1 + + def test_read_file_sensitive_approved_covers_subsequent(self): + rules = [ + ApprovedRule( + tool_name="read_file", + scope=PermissionScope.FILE_PATTERN, + session_pattern="*", + ) + ] + rp = RequiredPermission( + scope=PermissionScope.FILE_PATTERN, + invocation_pattern=".env.production", + session_pattern="*", + label="reading sensitive files (read_file)", + ) + assert self._is_covered("read_file", rp, rules) + + def test_different_tool_rule_doesnt_cover(self): + rules = [ + ApprovedRule( + tool_name="bash", + scope=PermissionScope.COMMAND_PATTERN, + session_pattern="mkdir *", + ) + ] + rp = RequiredPermission( + scope=PermissionScope.COMMAND_PATTERN, + invocation_pattern="mkdir foo", + session_pattern="mkdir *", + label="mkdir *", + ) + assert not self._is_covered("grep", rp, rules) + + +class TestWebFetchPermissions: + def _make_webfetch(self) -> WebFetch: + return WebFetch(config=WebFetchConfig(), state=BaseToolState()) + + def test_returns_url_pattern_with_domain(self): + wf = self._make_webfetch() + result = wf.resolve_permission( + WebFetchArgs(url="https://docs.python.org/3/library") + ) + assert isinstance(result, PermissionContext) + assert len(result.required_permissions) == 1 + rp = result.required_permissions[0] + assert rp.scope is PermissionScope.URL_PATTERN + assert rp.invocation_pattern == "docs.python.org" + assert rp.session_pattern == "docs.python.org" + assert "docs.python.org" in rp.label + + def test_http_url(self): + wf = self._make_webfetch() + result = wf.resolve_permission(WebFetchArgs(url="http://example.com/page")) + assert isinstance(result, PermissionContext) + rp = result.required_permissions[0] + assert rp.invocation_pattern == "example.com" + + def test_url_without_scheme(self): + wf = self._make_webfetch() + result = wf.resolve_permission(WebFetchArgs(url="github.com/anthropics")) + assert isinstance(result, PermissionContext) + rp = result.required_permissions[0] + assert rp.invocation_pattern == "github.com" + + def test_url_with_port(self): + wf = self._make_webfetch() + result = wf.resolve_permission(WebFetchArgs(url="http://localhost:8080/api")) + assert isinstance(result, PermissionContext) + rp = result.required_permissions[0] + assert rp.invocation_pattern == "localhost:8080" + + def test_url_without_scheme_with_port(self): + wf = self._make_webfetch() + result = wf.resolve_permission(WebFetchArgs(url="example.com:3000/path")) + assert isinstance(result, PermissionContext) + rp = result.required_permissions[0] + assert rp.invocation_pattern == "example.com:3000" + + def test_different_domains_not_covered(self): + rules = [ + ApprovedRule( + tool_name="web_fetch", + scope=PermissionScope.URL_PATTERN, + session_pattern="docs.python.org", + ) + ] + rp = RequiredPermission( + scope=PermissionScope.URL_PATTERN, + invocation_pattern="evil.com", + session_pattern="evil.com", + label="fetching from evil.com", + ) + covered = any( + rule.tool_name == "web_fetch" + and rule.scope == rp.scope + and wildcard_match(rp.invocation_pattern, rule.session_pattern) + for rule in rules + ) + assert not covered + + def test_same_domain_covered(self): + rules = [ + ApprovedRule( + tool_name="web_fetch", + scope=PermissionScope.URL_PATTERN, + session_pattern="docs.python.org", + ) + ] + rp = RequiredPermission( + scope=PermissionScope.URL_PATTERN, + invocation_pattern="docs.python.org", + session_pattern="docs.python.org", + label="fetching from docs.python.org", + ) + covered = any( + rule.tool_name == "web_fetch" + and rule.scope == rp.scope + and wildcard_match(rp.invocation_pattern, rule.session_pattern) + for rule in rules + ) + assert covered + + def test_double_slash_url(self): + wf = self._make_webfetch() + result = wf.resolve_permission(WebFetchArgs(url="//cdn.example.com/lib.js")) + assert isinstance(result, PermissionContext) + rp = result.required_permissions[0] + assert rp.invocation_pattern == "cdn.example.com" + + def test_config_permission_always_honored(self): + wf = WebFetch( + config=WebFetchConfig(permission=ToolPermission.ALWAYS), + state=BaseToolState(), + ) + result = wf.resolve_permission(WebFetchArgs(url="https://example.com")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ALWAYS + + def test_config_permission_never_honored(self): + wf = WebFetch( + config=WebFetchConfig(permission=ToolPermission.NEVER), + state=BaseToolState(), + ) + result = wf.resolve_permission(WebFetchArgs(url="https://example.com")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.NEVER + + def test_config_permission_ask_falls_through_to_domain(self): + wf = WebFetch( + config=WebFetchConfig(permission=ToolPermission.ASK), state=BaseToolState() + ) + result = wf.resolve_permission(WebFetchArgs(url="https://example.com")) + assert isinstance(result, PermissionContext) + assert result.required_permissions[0].invocation_pattern == "example.com" + + +class TestCollectOutsideDirs: + """Tests for _collect_outside_dirs helper.""" + + @pytest.fixture(autouse=True) + def _setup(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + self.workdir = tmp_path + + def test_relative_path_resolving_outside_workdir(self): + dirs = _collect_outside_dirs(["cat ../../etc/passwd"]) + # The relative path resolves outside workdir, should collect parent dir + assert len(dirs) >= 1 + + def test_multiple_targets_in_one_command(self): + dirs = _collect_outside_dirs(["cp /tmp/a /var/b"]) + assert len(dirs) == 2 + + def test_chmod_skips_plus_x_token(self): + dirs = _collect_outside_dirs(["chmod +x /tmp/script.sh"]) + # +x should be skipped, only /tmp/script.sh should be considered + assert len(dirs) >= 1 + # Verify no dir was created from the "+x" token + for d in dirs: + assert "+x" not in d + + def test_empty_command_list(self): + assert _collect_outside_dirs([]) == set() + + def test_home_relative_path(self): + home = os.path.expanduser("~") + dirs = _collect_outside_dirs(["cat ~/some_file"]) + # ~/some_file resolves to home directory, which is likely outside workdir + if home != str(self.workdir): + assert len(dirs) >= 1 + + def test_in_workdir_path_not_collected(self): + (self.workdir / "local_file").touch() + dirs = _collect_outside_dirs(["cat ./local_file"]) + assert len(dirs) == 0 + + def test_traversal_path_without_dot_prefix(self): + """Paths like src/../../../etc/passwd don't start with . but contain /.""" + (self.workdir / "src").mkdir() + dirs = _collect_outside_dirs(["cat src/../../../etc/passwd"]) + assert len(dirs) >= 1 + + def test_in_workdir_subdir_path_not_collected(self): + """foo/bar inside workdir should not be flagged.""" + (self.workdir / "foo").mkdir() + (self.workdir / "foo" / "bar").touch() + dirs = _collect_outside_dirs(["cat foo/bar"]) + assert len(dirs) == 0 diff --git a/tests/tools/test_invoke_context.py b/tests/tools/test_invoke_context.py index dbf44b2..753853b 100644 --- a/tests/tools/test_invoke_context.py +++ b/tests/tools/test_invoke_context.py @@ -46,7 +46,10 @@ class TestInvokeContext: def test_approval_callback_can_be_set(self) -> None: async def dummy_callback( - _tool_name: str, _args: BaseModel, _tool_call_id: str + _tool_name: str, + _args: BaseModel, + _tool_call_id: str, + _rp: list | None = None, ) -> tuple[ApprovalResponse, str | None]: return ApprovalResponse.YES, None @@ -76,7 +79,10 @@ class TestToolInvokeWithContext: @pytest.mark.asyncio async def test_invoke_with_approval_callback(self, simple_tool: SimpleTool) -> None: async def dummy_callback( - _tool_name: str, _args: BaseModel, _tool_call_id: str + _tool_name: str, + _args: BaseModel, + _tool_call_id: str, + _rp: list | None = None, ) -> tuple[ApprovalResponse, str | None]: return ApprovalResponse.YES, None diff --git a/tests/tools/test_manager_get_tool_config.py b/tests/tools/test_manager_get_tool_config.py index ae7f66b..567eb2e 100644 --- a/tests/tools/test_manager_get_tool_config.py +++ b/tests/tools/test_manager_get_tool_config.py @@ -36,7 +36,7 @@ def test_merges_user_overrides_with_defaults(): vibe_config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, - tools={"bash": BaseToolConfig(permission=ToolPermission.ALWAYS)}, + tools={"bash": {"permission": "always"}}, ) manager = ToolManager(lambda: vibe_config) @@ -53,9 +53,9 @@ def test_preserves_tool_specific_fields_from_overrides(): vibe_config = build_test_vibe_config( system_prompt_id="tests", include_project_context=False, - tools={"bash": BaseToolConfig(permission=ToolPermission.ASK)}, + tools={"bash": {"permission": "ask"}}, ) - vibe_config.tools["bash"].__pydantic_extra__ = {"default_timeout": 600} + vibe_config.tools["bash"]["default_timeout"] = 600 manager = ToolManager(lambda: vibe_config) config = manager.get_tool_config("bash") @@ -71,6 +71,23 @@ def test_falls_back_to_base_config_for_unknown_tool(tool_manager): assert config.permission == ToolPermission.ASK +def test_partial_override_preserves_tool_defaults(): + vibe_config = build_test_vibe_config( + system_prompt_id="tests", + include_project_context=False, + tools={"read_file": {"max_read_bytes": 32000}}, + ) + manager = ToolManager(lambda: vibe_config) + + config = manager.get_tool_config("read_file") + + assert ( + config.permission == ToolPermission.ALWAYS + ) # ReadFileToolConfig default, not BaseToolConfig.ASK + assert config.sensitive_patterns == ["**/.env", "**/.env.*"] # type: ignore[attr-defined] + assert config.max_read_bytes == 32000 # type: ignore[attr-defined] + + class TestToolManagerFiltering: def test_enabled_tools_filters_to_only_enabled(self): vibe_config = build_test_vibe_config( diff --git a/tests/tools/test_skill.py b/tests/tools/test_skill.py new file mode 100644 index 0000000..3fce5d1 --- /dev/null +++ b/tests/tools/test_skill.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from tests.mock.utils import collect_result +from vibe.core.skills.manager import SkillManager +from vibe.core.skills.models import SkillInfo +from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError +from vibe.core.tools.builtins.skill import ( + Skill, + SkillArgs, + SkillResult, + SkillToolConfig, +) +from vibe.core.tools.permissions import PermissionScope + + +def _make_skill_dir( + tmp_path: Path, + name: str = "my-skill", + description: str = "A test skill", + body: str = "## Instructions\n\nDo the thing.", + extra_files: list[str] | None = None, +) -> SkillInfo: + skill_dir = tmp_path / name + skill_dir.mkdir(parents=True, exist_ok=True) + + content = f"---\nname: {name}\ndescription: {description}\n---\n\n{body}" + (skill_dir / "SKILL.md").write_text(content, encoding="utf-8") + + for f in extra_files or []: + file_path = skill_dir / f + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(f"content of {f}", encoding="utf-8") + + return SkillInfo( + name=name, description=description, skill_path=skill_dir / "SKILL.md" + ) + + +def _make_skill_manager(skills: dict[str, SkillInfo]) -> SkillManager: + manager = MagicMock(spec=SkillManager) + manager.available_skills = skills + manager.get_skill.side_effect = lambda n: skills.get(n) + return manager + + +def _make_ctx(skill_manager: SkillManager | None = None) -> InvokeContext: + return InvokeContext(tool_call_id="test-call", skill_manager=skill_manager) + + +@pytest.fixture +def skill_tool() -> Skill: + return Skill(config=SkillToolConfig(), state=BaseToolState()) + + +class TestSkillRun: + @pytest.mark.asyncio + async def test_loads_skill_content(self, tmp_path: Path, skill_tool: Skill) -> None: + info = _make_skill_dir(tmp_path, body="Follow these steps:\n1. Do A\n2. Do B") + manager = _make_skill_manager({"my-skill": info}) + ctx = _make_ctx(manager) + + result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx)) + + assert isinstance(result, SkillResult) + assert result.name == "my-skill" + assert "Follow these steps:" in result.content + assert "1. Do A" in result.content + assert '' in result.content + assert "# Skill: my-skill" in result.content + assert "" in result.content + + @pytest.mark.asyncio + async def test_lists_bundled_files(self, tmp_path: Path, skill_tool: Skill) -> None: + info = _make_skill_dir( + tmp_path, extra_files=["scripts/run.sh", "references/guide.md"] + ) + manager = _make_skill_manager({"my-skill": info}) + ctx = _make_ctx(manager) + + result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx)) + + assert "" in result.content + assert "scripts/run.sh" in result.content + assert "references/guide.md" in result.content + assert f"{info.skill_dir / 'scripts/run.sh'}" not in result.content + + @pytest.mark.asyncio + async def test_excludes_skill_md_from_file_list( + self, tmp_path: Path, skill_tool: Skill + ) -> None: + info = _make_skill_dir(tmp_path, extra_files=["helper.py"]) + manager = _make_skill_manager({"my-skill": info}) + ctx = _make_ctx(manager) + + result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx)) + + assert "SKILL.md" not in result.content.split("")[1] + assert "helper.py" in result.content + + @pytest.mark.asyncio + async def test_caps_file_list_at_ten( + self, tmp_path: Path, skill_tool: Skill + ) -> None: + files = [f"file{i:02d}.txt" for i in range(15)] + info = _make_skill_dir(tmp_path, extra_files=files) + manager = _make_skill_manager({"my-skill": info}) + ctx = _make_ctx(manager) + + result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx)) + + file_section = result.content.split("")[1].split("")[ + 0 + ] + assert file_section.count("") == 10 + + @pytest.mark.asyncio + async def test_empty_skill_directory( + self, tmp_path: Path, skill_tool: Skill + ) -> None: + info = _make_skill_dir(tmp_path) + manager = _make_skill_manager({"my-skill": info}) + ctx = _make_ctx(manager) + + result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx)) + + assert "\n\n" in result.content + + @pytest.mark.asyncio + async def test_returns_skill_dir(self, tmp_path: Path, skill_tool: Skill) -> None: + info = _make_skill_dir(tmp_path) + manager = _make_skill_manager({"my-skill": info}) + ctx = _make_ctx(manager) + + result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx)) + + assert result.skill_dir == str(info.skill_dir) + + @pytest.mark.asyncio + async def test_includes_base_directory( + self, tmp_path: Path, skill_tool: Skill + ) -> None: + info = _make_skill_dir(tmp_path) + manager = _make_skill_manager({"my-skill": info}) + ctx = _make_ctx(manager) + + result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx)) + + assert f"Base directory for this skill: {info.skill_dir}" in result.content + + +class TestSkillErrors: + @pytest.mark.asyncio + async def test_no_context(self, skill_tool: Skill) -> None: + with pytest.raises(ToolError, match="Skill manager not available"): + await collect_result(skill_tool.run(SkillArgs(name="test"), ctx=None)) + + @pytest.mark.asyncio + async def test_no_skill_manager(self, skill_tool: Skill) -> None: + ctx = _make_ctx(skill_manager=None) + with pytest.raises(ToolError, match="Skill manager not available"): + await collect_result(skill_tool.run(SkillArgs(name="test"), ctx=ctx)) + + @pytest.mark.asyncio + async def test_skill_not_found(self, skill_tool: Skill) -> None: + manager = _make_skill_manager({"alpha": MagicMock(), "beta": MagicMock()}) + ctx = _make_ctx(manager) + + with pytest.raises(ToolError, match='Skill "missing" not found'): + await collect_result(skill_tool.run(SkillArgs(name="missing"), ctx=ctx)) + + @pytest.mark.asyncio + async def test_skill_not_found_lists_available(self, skill_tool: Skill) -> None: + manager = _make_skill_manager({"alpha": MagicMock(), "beta": MagicMock()}) + ctx = _make_ctx(manager) + + with pytest.raises(ToolError, match="alpha, beta"): + await collect_result(skill_tool.run(SkillArgs(name="missing"), ctx=ctx)) + + @pytest.mark.asyncio + async def test_unreadable_skill_file( + self, tmp_path: Path, skill_tool: Skill + ) -> None: + info = SkillInfo( + name="broken", + description="Broken skill", + skill_path=tmp_path / "nonexistent" / "SKILL.md", + ) + manager = _make_skill_manager({"broken": info}) + ctx = _make_ctx(manager) + + with pytest.raises(ToolError, match="Cannot load skill file"): + await collect_result(skill_tool.run(SkillArgs(name="broken"), ctx=ctx)) + + +class TestSkillPermission: + def test_resolve_permission_returns_file_pattern(self, skill_tool: Skill) -> None: + perm = skill_tool.resolve_permission(SkillArgs(name="my-skill")) + + assert perm is not None + assert len(perm.required_permissions) == 1 + assert perm.required_permissions[0].scope == PermissionScope.FILE_PATTERN + assert perm.required_permissions[0].invocation_pattern == "my-skill" + assert perm.required_permissions[0].session_pattern == "my-skill" + + +class TestSkillMeta: + def test_tool_name(self) -> None: + assert Skill.get_name() == "skill" + + def test_description_is_set(self) -> None: + assert "skill" in Skill.description.lower() + assert len(Skill.description) > 20 diff --git a/tests/tools/test_task.py b/tests/tools/test_task.py index c042c13..7372f7d 100644 --- a/tests/tools/test_task.py +++ b/tests/tools/test_task.py @@ -10,6 +10,7 @@ from vibe.core.agents.manager import AgentManager from vibe.core.agents.models import BUILTIN_AGENTS, AgentType from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError, ToolPermission from vibe.core.tools.builtins.task import Task, TaskArgs, TaskResult, TaskToolConfig +from vibe.core.tools.permissions import PermissionContext from vibe.core.types import AssistantEvent, LLMMessage, Role @@ -80,7 +81,8 @@ class TestTaskToolResolvePermission: def test_explore_allowed_by_default(self, task_tool: Task) -> None: args = TaskArgs(task="do something", agent="explore") result = task_tool.resolve_permission(args) - assert result == ToolPermission.ALWAYS + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ALWAYS def test_unknown_agent_returns_none(self, task_tool: Task) -> None: args = TaskArgs(task="do something", agent="custom_agent") @@ -92,21 +94,24 @@ class TestTaskToolResolvePermission: tool = Task(config=config, state=BaseToolState()) args = TaskArgs(task="do something", agent="explore") result = tool.resolve_permission(args) - assert result == ToolPermission.NEVER + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.NEVER def test_glob_pattern_in_allowlist(self) -> None: config = TaskToolConfig(allowlist=["exp*"]) tool = Task(config=config, state=BaseToolState()) args = TaskArgs(task="do something", agent="explore") result = tool.resolve_permission(args) - assert result == ToolPermission.ALWAYS + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ALWAYS def test_glob_pattern_in_denylist(self) -> None: config = TaskToolConfig(denylist=["danger*"]) tool = Task(config=config, state=BaseToolState()) args = TaskArgs(task="do something", agent="dangerous_agent") result = tool.resolve_permission(args) - assert result == ToolPermission.NEVER + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.NEVER def test_empty_lists_returns_none(self) -> None: config = TaskToolConfig(allowlist=[], denylist=[]) diff --git a/tests/tools/test_websearch.py b/tests/tools/test_websearch.py index 7a30196..02ba64d 100644 --- a/tests/tools/test_websearch.py +++ b/tests/tools/test_websearch.py @@ -14,9 +14,10 @@ from mistralai.client.models import ( import pytest from tests.mock.utils import collect_result -from vibe.core.config import Backend, ProviderConfig +from vibe.core.config import ProviderConfig from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError from vibe.core.tools.builtins.websearch import WebSearch, WebSearchArgs, WebSearchConfig +from vibe.core.types import Backend def _make_response( diff --git a/tests/tools/test_wildcard_match.py b/tests/tools/test_wildcard_match.py new file mode 100644 index 0000000..22cb27b --- /dev/null +++ b/tests/tools/test_wildcard_match.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from vibe.core.tools.utils import wildcard_match + + +class TestWildcardMatch: + def test_exact_match(self): + assert wildcard_match("hello", "hello") + + def test_exact_no_match(self): + assert not wildcard_match("hello", "world") + + def test_star_matches_any(self): + assert wildcard_match("hello world", "hello *") + + def test_star_matches_empty_trailing(self): + assert wildcard_match("mkdir", "mkdir *") + + def test_star_matches_with_args(self): + assert wildcard_match("mkdir hello", "mkdir *") + + def test_star_matches_long_trailing(self): + assert wildcard_match("git commit -m hello world", "git commit *") + + def test_star_in_middle(self): + assert wildcard_match("fooXbar", "foo*bar") + + def test_question_mark_single_char(self): + assert wildcard_match("cat", "c?t") + + def test_question_mark_no_match(self): + assert not wildcard_match("ct", "c?t") + + def test_glob_path_pattern(self): + assert wildcard_match("/tmp/dir/file.txt", "/tmp/dir/*") + + def test_glob_nested_path(self): + assert wildcard_match("/tmp/dir/sub/file.txt", "/tmp/dir/*") + + def test_glob_no_match(self): + assert not wildcard_match("/home/user/file.txt", "/tmp/*") + + def test_special_regex_chars_in_text(self): + assert wildcard_match("echo (hello)", "echo *") + + def test_special_regex_chars_in_pattern(self): + assert wildcard_match(".env", ".env") + assert not wildcard_match("xenv", ".env") + + def test_fnmatch_character_class(self): + assert wildcard_match("vache", "[bcghlmstv]ache") + + def test_empty_pattern_empty_text(self): + assert wildcard_match("", "") + + def test_star_only(self): + assert wildcard_match("anything goes here", "*") + + def test_trailing_space_star_is_optional(self): + assert wildcard_match("ls", "ls *") + assert wildcard_match("ls -la", "ls *") + assert wildcard_match("ls -la /tmp", "ls *") + + def test_non_trailing_star_is_greedy(self): + assert wildcard_match("abc123def", "abc*def") + assert not wildcard_match("abc123de", "abc*def") diff --git a/tests/transcribe/test_transcribe_client.py b/tests/transcribe/test_transcribe_client.py index ef62a9b..c9f5034 100644 --- a/tests/transcribe/test_transcribe_client.py +++ b/tests/transcribe/test_transcribe_client.py @@ -39,10 +39,17 @@ async def _empty_audio_stream() -> AsyncIterator[bytes]: yield -def _make_sdk_session_created() -> MagicMock: - from mistralai.client.models import RealtimeTranscriptionSessionCreated +def _make_sdk_session_created(request_id: str = "test-request-id") -> MagicMock: + from mistralai.client.models import ( + RealtimeTranscriptionSession, + RealtimeTranscriptionSessionCreated, + ) - return MagicMock(spec=RealtimeTranscriptionSessionCreated) + session = MagicMock(spec=RealtimeTranscriptionSession) + session.request_id = request_id + mock = MagicMock(spec=RealtimeTranscriptionSessionCreated) + mock.session = session + return mock def _make_sdk_text_delta(text: str) -> MagicMock: @@ -104,6 +111,7 @@ class TestEventMapping: assert len(events) == 1 assert isinstance(events[0], TranscribeSessionCreated) + assert events[0].request_id == "test-request-id" @pytest.mark.asyncio async def test_text_delta(self) -> None: diff --git a/tests/tts/test_tts_client.py b/tests/tts/test_tts_client.py new file mode 100644 index 0000000..6cdff4f --- /dev/null +++ b/tests/tts/test_tts_client.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import base64 + +import httpx +import pytest + +from vibe.core.config import TTSModelConfig, TTSProviderConfig +from vibe.core.tts import MistralTTSClient, TTSResult + + +def _make_provider() -> TTSProviderConfig: + return TTSProviderConfig( + name="mistral", + api_base="https://api.mistral.ai", + api_key_env_var="MISTRAL_API_KEY", + ) + + +def _make_model() -> TTSModelConfig: + return TTSModelConfig( + name="voxtral-mini-tts-latest", + alias="voxtral-tts", + provider="mistral", + voice="gb_jane_neutral", + ) + + +class TestMistralTTSClientInit: + def test_client_configured_with_base_url_and_auth( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("MISTRAL_API_KEY", "test-key") + client = MistralTTSClient(_make_provider(), _make_model()) + assert str(client._client.base_url) == "https://api.mistral.ai/v1/" + assert client._client.headers["authorization"] == "Bearer test-key" + + +class TestMistralTTSClient: + @pytest.mark.asyncio + async def test_speak_returns_decoded_audio( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("MISTRAL_API_KEY", "test-key") + + raw_audio = b"fake-audio-data-for-testing" + encoded_audio = base64.b64encode(raw_audio).decode() + + async def mock_post(self_client, url, **kwargs): + assert url == "/audio/speech" + body = kwargs["json"] + assert body["model"] == "voxtral-mini-tts-latest" + assert body["input"] == "Hello" + assert body["voice_id"] == "gb_jane_neutral" + assert body["stream"] is False + assert body["response_format"] == "wav" + return httpx.Response( + status_code=200, + json={"audio_data": encoded_audio}, + request=httpx.Request("POST", url), + ) + + monkeypatch.setattr(httpx.AsyncClient, "post", mock_post) + + client = MistralTTSClient(_make_provider(), _make_model()) + result = await client.speak("Hello") + + assert isinstance(result, TTSResult) + assert result.audio_data == raw_audio + await client.close() + + @pytest.mark.asyncio + async def test_speak_raises_on_http_error( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("MISTRAL_API_KEY", "test-key") + + async def mock_post(self_client, url, **kwargs): + return httpx.Response( + status_code=500, + json={"error": "Internal Server Error"}, + request=httpx.Request("POST", url), + ) + + monkeypatch.setattr(httpx.AsyncClient, "post", mock_post) + + client = MistralTTSClient(_make_provider(), _make_model()) + with pytest.raises(httpx.HTTPStatusError): + await client.speak("Hello") + await client.close() + + @pytest.mark.asyncio + async def test_close_closes_underlying_client( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("MISTRAL_API_KEY", "test-key") + + client = MistralTTSClient(_make_provider(), _make_model()) + await client.close() + assert client._client.is_closed diff --git a/tests/voice_manager/test_telemetry.py b/tests/voice_manager/test_telemetry.py new file mode 100644 index 0000000..d692856 --- /dev/null +++ b/tests/voice_manager/test_telemetry.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from vibe.cli.voice_manager.telemetry import TranscriptionTrackingState + + +class TestTranscriptionTrackingState: + def test_reset_clears_accumulated_state(self) -> None: + state = TranscriptionTrackingState() + state.set_recording_id("req-1") + state.record_text("hello") + state.set_recording_duration(5.0) + + state.reset() + assert state.recording_id == "" + assert state.accumulated_transcript_length == 0 + assert state.last_recording_duration_ms is None + + def test_set_recording_id(self) -> None: + state = TranscriptionTrackingState() + state.set_recording_id("req-abc") + assert state.recording_id == "req-abc" + + def test_record_text_accumulates_length(self) -> None: + state = TranscriptionTrackingState() + state.reset() + state.record_text("hello ") + state.record_text("world") + assert state.accumulated_transcript_length == 11 + + def test_elapsed_ms_returns_positive_value(self) -> None: + state = TranscriptionTrackingState() + state.reset() + assert state.elapsed_ms() >= 0 + + def test_set_recording_duration_converts_seconds_to_ms(self) -> None: + state = TranscriptionTrackingState() + state.set_recording_duration(2.5) + assert state.last_recording_duration_ms == 2500.0 + + def test_default_state(self) -> None: + state = TranscriptionTrackingState() + assert state.recording_id == "" + assert state.accumulated_transcript_length == 0 + assert state.last_recording_duration_ms is None diff --git a/tests/voice_manager/test_voice_manager.py b/tests/voice_manager/test_voice_manager.py index 9148adf..68503f2 100644 --- a/tests/voice_manager/test_voice_manager.py +++ b/tests/voice_manager/test_voice_manager.py @@ -1,6 +1,6 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -47,12 +47,16 @@ def _make_manager( *, voice_mode_enabled: bool = True, transcribe_client: FakeTranscribeClient | None = None, + telemetry_client: MagicMock | None = None, ) -> tuple[VoiceManager, FakeAudioRecorder, FakeTranscribeClient]: recorder = FakeAudioRecorder() client = transcribe_client or FakeTranscribeClient() config = build_test_vibe_config(voice_mode_enabled=voice_mode_enabled) manager = VoiceManager( - config_getter=lambda: config, audio_recorder=recorder, transcribe_client=client + config_getter=lambda: config, + audio_recorder=recorder, + transcribe_client=client, + telemetry_client=telemetry_client, ) return manager, recorder, client @@ -175,7 +179,7 @@ class TestStopRecording: async def test_stop_recovers_when_no_audio_was_sent(self) -> None: client = FakeTranscribeClient( events=[ - TranscribeSessionCreated(), + TranscribeSessionCreated(request_id="test-req-id"), TranscribeError( message="Cannot flush audio before sending any audio bytes" ), @@ -304,7 +308,7 @@ class TestTranscription: async def test_text_deltas_notify_listeners(self) -> None: client = FakeTranscribeClient( events=[ - TranscribeSessionCreated(), + TranscribeSessionCreated(request_id="test-req-id"), TranscribeTextDelta(text="hello "), TranscribeTextDelta(text="world"), TranscribeDone(), @@ -323,7 +327,7 @@ class TestTranscription: async def test_transcription_error_does_not_crash(self) -> None: client = FakeTranscribeClient( events=[ - TranscribeSessionCreated(), + TranscribeSessionCreated(request_id="test-req-id"), TranscribeTextDelta(text="partial"), TranscribeError(message="something broke"), TranscribeDone(), @@ -341,7 +345,10 @@ class TestTranscription: @pytest.mark.asyncio async def test_cancel_during_transcription(self) -> None: client = FakeTranscribeClient( - events=[TranscribeSessionCreated(), TranscribeTextDelta(text="hello")] + events=[ + TranscribeSessionCreated(request_id="test-req-id"), + TranscribeTextDelta(text="hello"), + ] ) manager, _, _ = _make_manager(transcribe_client=client) listener = StateListener() @@ -355,7 +362,10 @@ class TestTranscription: @pytest.mark.asyncio async def test_session_created_is_silent(self) -> None: client = FakeTranscribeClient( - events=[TranscribeSessionCreated(), TranscribeDone()] + events=[ + TranscribeSessionCreated(request_id="test-req-id"), + TranscribeDone(), + ] ) manager, _, _ = _make_manager(transcribe_client=client) listener = StateListener() @@ -392,3 +402,178 @@ class TestTranscription: assert manager.transcribe_state == TranscribeState.IDLE assert not recorder.is_recording + + +def _find_telemetry_calls( + mock: MagicMock, event_name: str +) -> list[dict[str, str | int | float | None]]: + """Return the properties dicts for all calls matching a given event name.""" + results: list[dict[str, str | int | float | None]] = [] + for call in mock.send_telemetry_event.call_args_list: + if call[0][0] == event_name: + results.append(call[0][1]) + return results + + +class TestTelemetryTracking: + @pytest.mark.asyncio + async def test_start_sends_transcription_start_event(self) -> None: + client = FakeTranscribeClient( + events=[TranscribeSessionCreated(request_id="req-123"), TranscribeDone()] + ) + mock_telemetry = MagicMock() + manager, _, _ = _make_manager( + transcribe_client=client, telemetry_client=mock_telemetry + ) + manager.start_recording() + await manager.stop_recording() + + calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.start") + assert len(calls) == 1 + assert calls[0]["recording_id"] == "req-123" + + @pytest.mark.asyncio + async def test_cancel_sends_cancel_event(self) -> None: + mock_telemetry = MagicMock() + manager, _, _ = _make_manager(telemetry_client=mock_telemetry) + manager.start_recording() + manager.cancel_recording() + + calls = _find_telemetry_calls( + mock_telemetry, "vibe.audio.transcription.cancel_recording" + ) + assert len(calls) == 1 + recording_duration_ms = calls[0]["recording_duration_ms"] + assert isinstance(recording_duration_ms, (int, float)) + assert recording_duration_ms >= 0 + + @pytest.mark.asyncio + async def test_done_sends_done_event(self) -> None: + client = FakeTranscribeClient( + events=[ + TranscribeSessionCreated(request_id="test-req-id"), + TranscribeTextDelta(text="hello "), + TranscribeTextDelta(text="world"), + TranscribeDone(), + ] + ) + mock_telemetry = MagicMock() + manager, _, _ = _make_manager( + transcribe_client=client, telemetry_client=mock_telemetry + ) + + manager.start_recording() + await manager.stop_recording() + + calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.done") + assert len(calls) == 1 + assert calls[0]["recording_id"] == "test-req-id" + assert calls[0]["transcript_length"] == len("hello ") + len("world") + transcription_duration_ms = calls[0]["transcription_duration_ms"] + assert isinstance(transcription_duration_ms, (int, float)) + assert transcription_duration_ms >= 0 + recording_duration_ms = calls[0]["recording_duration_ms"] + assert isinstance(recording_duration_ms, (int, float)) + assert recording_duration_ms >= 0 + + @pytest.mark.asyncio + async def test_error_sends_error_event(self) -> None: + import asyncio + + class CrashingTranscribeClient: + def __init__(self, provider=None, model=None) -> None: + pass + + async def transcribe(self, audio_stream): + raise RuntimeError("network error") + yield + + recorder = FakeAudioRecorder() + config = build_test_vibe_config(voice_mode_enabled=True) + mock_telemetry = MagicMock() + manager = VoiceManager( + config_getter=lambda: config, + audio_recorder=recorder, + transcribe_client=CrashingTranscribeClient(), + telemetry_client=mock_telemetry, + ) + + manager.start_recording() + await asyncio.sleep(0) + + calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.error") + assert len(calls) == 1 + error_message = calls[0]["error_message"] + assert isinstance(error_message, str) + assert "network error" in error_message + transcription_duration_ms = calls[0]["transcription_duration_ms"] + assert isinstance(transcription_duration_ms, (int, float)) + assert transcription_duration_ms >= 0 + + @pytest.mark.asyncio + async def test_no_telemetry_when_client_is_none(self) -> None: + manager, _, _ = _make_manager() # no telemetry_client + manager.start_recording() + manager.cancel_recording() + # No error raised — tracking is silently skipped + + @pytest.mark.asyncio + async def test_each_recording_uses_session_request_id(self) -> None: + client = FakeTranscribeClient( + events=[TranscribeSessionCreated(request_id="req-first"), TranscribeDone()] + ) + mock_telemetry = MagicMock() + manager, _, _ = _make_manager( + transcribe_client=client, telemetry_client=mock_telemetry + ) + + manager.start_recording() + await manager.stop_recording() + + client.set_events([ + TranscribeSessionCreated(request_id="req-second"), + TranscribeDone(), + ]) + + manager.start_recording() + await manager.stop_recording() + + calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.start") + assert len(calls) == 2 + assert calls[0]["recording_id"] == "req-first" + assert calls[1]["recording_id"] == "req-second" + + @pytest.mark.asyncio + async def test_timeout_sends_error_event(self) -> None: + import asyncio + + class HangingTranscribeClient: + def __init__(self, provider=None, model=None) -> None: + pass + + async def transcribe(self, audio_stream): + await asyncio.Event().wait() + return + yield + + recorder = FakeAudioRecorder() + config = build_test_vibe_config(voice_mode_enabled=True) + mock_telemetry = MagicMock() + manager = VoiceManager( + config_getter=lambda: config, + audio_recorder=recorder, + transcribe_client=HangingTranscribeClient(), + telemetry_client=mock_telemetry, + ) + manager.start_recording() + + with patch( + "vibe.cli.voice_manager.voice_manager.TRANSCRIPTION_DRAIN_TIMEOUT", 0.01 + ): + await manager.stop_recording() + + calls = _find_telemetry_calls(mock_telemetry, "vibe.audio.transcription.error") + assert len(calls) == 1 + error_message = calls[0]["error_message"] + assert isinstance(error_message, str) + assert "timed out" in error_message.lower() diff --git a/uv.lock b/uv.lock index d7ed58f..1c8ffd1 100644 --- a/uv.lock +++ b/uv.lock @@ -4,14 +4,14 @@ requires-python = ">=3.12" [[package]] name = "agent-client-protocol" -version = "0.8.1" +version = "0.9.0a1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1b/7b/7cdac86db388809d9e3bc58cac88cc7dfa49b7615b98fab304a828cd7f8a/agent_client_protocol-0.8.1.tar.gz", hash = "sha256:1bbf15663bf51f64942597f638e32a6284c5da918055d9672d3510e965143dbd", size = 68866, upload-time = "2026-02-13T15:34:54.567Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/dc/1ec56897b461fbdb844c9bff3abbbe225bfe8cda020dc2449101a6d76592/agent_client_protocol-0.9.0a1.tar.gz", hash = "sha256:9e6fc8b72df465279470920d679c871e0c658f69212e345563cc69b17906b606", size = 70423, upload-time = "2026-03-19T18:44:47.117Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/f3/219eeca0ad4a20843d4b9eaac5532f87018b9d25730a62a16f54f6c52d1a/agent_client_protocol-0.8.1-py3-none-any.whl", hash = "sha256:9421a11fd435b4831660272d169c3812d553bb7247049c138c3ca127e4b8af8e", size = 54529, upload-time = "2026-02-13T15:34:53.344Z" }, + { url = "https://files.pythonhosted.org/packages/cd/59/b794d5247aac2693a562ddd6eb2092581331496815967a6bca6bbf086b4f/agent_client_protocol-0.9.0a1-py3-none-any.whl", hash = "sha256:3e0962df15c3c7dd2957daea2f47db5e644fd897e77180e492f0a27d9fdb7bf4", size = 55945, upload-time = "2026-03-19T18:44:45.924Z" }, ] [[package]] @@ -398,6 +398,18 @@ 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 = "googleapis-common-protos" +version = "1.73.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -758,7 +770,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.5.0" +version = "2.6.0" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, @@ -773,6 +785,10 @@ dependencies = [ { name = "markdownify" }, { name = "mcp" }, { name = "mistralai" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, { name = "packaging" }, { name = "pexpect" }, { name = "pydantic" }, @@ -796,6 +812,7 @@ dependencies = [ [package.dev-dependencies] build = [ { name = "pyinstaller" }, + { name = "truststore" }, ] dev = [ { name = "debugpy" }, @@ -815,7 +832,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "agent-client-protocol", specifier = "==0.8.1" }, + { name = "agent-client-protocol", specifier = "==0.9.0a1" }, { name = "anyio", specifier = ">=4.12.0" }, { name = "cachetools", specifier = ">=5.5.0" }, { name = "cryptography", specifier = ">=44.0.0,<=46.0.3" }, @@ -827,6 +844,10 @@ requires-dist = [ { name = "markdownify", specifier = ">=1.2.2" }, { name = "mcp", specifier = ">=1.14.0" }, { name = "mistralai", specifier = "==2.0.0" }, + { name = "opentelemetry-api", specifier = ">=1.39.1" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.39.1" }, + { name = "opentelemetry-sdk", specifier = ">=1.39.1" }, + { name = "opentelemetry-semantic-conventions", specifier = ">=0.60b1" }, { name = "packaging", specifier = ">=24.1" }, { name = "pexpect", specifier = ">=4.9.0" }, { name = "pydantic", specifier = ">=2.12.4" }, @@ -848,7 +869,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -build = [{ name = "pyinstaller", specifier = ">=6.17.0" }] +build = [ + { name = "pyinstaller", specifier = ">=6.17.0" }, + { name = "truststore", specifier = ">=0.10.4" }, +] dev = [ { name = "debugpy", specifier = ">=1.8.19" }, { name = "pre-commit", specifier = ">=4.2.0" }, @@ -948,6 +972,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, ] +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + [[package]] name = "opentelemetry-semantic-conventions" version = "0.60b1" @@ -1025,6 +1105,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, +] + [[package]] name = "ptyprocess" version = "0.7.0" @@ -1865,6 +1960,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/75/4ca1a9fabd8fb5aea78cea70f7837ce4dbf2afae115f62051e5fa99cba1c/tree_sitter_bash-0.25.1-cp310-abi3-win_arm64.whl", hash = "sha256:59115057ec2bae319e8082ff29559861045002964c3431ccb0fc92aa4bc9bccb", size = 191196, upload-time = "2025-12-02T17:01:07.486Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "twine" version = "6.2.0" diff --git a/vibe-acp.spec b/vibe-acp.spec index 6542891..569b785 100644 --- a/vibe-acp.spec +++ b/vibe-acp.spec @@ -1,4 +1,7 @@ # -*- mode: python ; coding: utf-8 -*- +# Onedir build for vibe-acp — no per-launch extraction overhead. +# Build: uv run --group build pyinstaller vibe-acp.spec +# Output: dist/vibe-acp-dir/vibe-acp (+ dist/vibe-acp-dir/_internal/) from PyInstaller.utils.hooks import collect_all @@ -7,7 +10,7 @@ core_builtins_deps = collect_all('vibe.core.tools.builtins') acp_builtins_deps = collect_all('vibe.acp.tools.builtins') # Extract hidden imports and binaries, filtering to ensure only strings are in hiddenimports -hidden_imports = [] +hidden_imports = ["truststore"] for item in core_builtins_deps[2] + acp_builtins_deps[2]: if isinstance(item, str): hidden_imports.append(item) @@ -31,7 +34,7 @@ a = Analysis( hiddenimports=hidden_imports, hookspath=[], hooksconfig={}, - runtime_hooks=[], + runtime_hooks=["pyinstaller/runtime_hook_truststore.py"], excludes=[], noarchive=False, optimize=0, @@ -41,8 +44,6 @@ pyz = PYZ(a.pure) exe = EXE( pyz, a.scripts, - a.binaries, - a.datas, [], name='vibe-acp', debug=False, @@ -50,7 +51,6 @@ exe = EXE( strip=False, upx=True, upx_exclude=[], - runtime_tmpdir=None, console=True, disable_windowed_traceback=False, argv_emulation=False, @@ -58,3 +58,13 @@ exe = EXE( codesign_identity=None, entitlements_file=None, ) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='vibe-acp-dir', +) diff --git a/vibe/__init__.py b/vibe/__init__.py index f2dd2b5..77cd643 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.5.0" +__version__ = "2.6.0" diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py index b07cde1..74794ea 100644 --- a/vibe/acp/acp_agent_loop.py +++ b/vibe/acp/acp_agent_loop.py @@ -26,11 +26,14 @@ from acp.schema import ( AgentThoughtChunk, AllowedOutcome, AuthenticateResponse, - AuthMethod, + AuthMethodAgent, AvailableCommand, AvailableCommandInput, ClientCapabilities, + CloseSessionResponse, ContentToolCallContent, + Cost, + EnvVarAuthMethod, ForkSessionResponse, HttpMcpServer, Implementation, @@ -43,11 +46,14 @@ from acp.schema import ( SessionListCapabilities, SetSessionConfigOptionResponse, SseMcpServer, + TerminalAuthMethod, TextContentBlock, TextResourceContents, ToolCallProgress, ToolCallUpdate, UnstructuredCommandInput, + Usage, + UsageUpdate, UserMessageChunk, ) from pydantic import BaseModel, ConfigDict @@ -71,8 +77,8 @@ from vibe.acp.tools.session_update import ( tool_result_session_update, ) from vibe.acp.utils import ( - TOOL_OPTIONS, ToolOption, + build_permission_options, create_assistant_message_replay, create_compact_end_session_update, create_compact_start_session_update, @@ -101,8 +107,9 @@ from vibe.core.proxy_setup import ( unset_proxy_var, ) from vibe.core.session.session_loader import SessionLoader -from vibe.core.tools.base import BaseToolConfig, ToolPermission +from vibe.core.tools.permissions import RequiredPermission from vibe.core.types import ( + AgentProfileChangedEvent, ApprovalCallback, ApprovalResponse, AssistantEvent, @@ -173,9 +180,10 @@ class VibeAcpAgentLoop(AcpAgent): and self.client_capabilities.field_meta.get("terminal-auth") is True ) - auth_methods = ( + auth_methods: list[EnvVarAuthMethod | TerminalAuthMethod | AuthMethodAgent] = ( [ - AuthMethod( + TerminalAuthMethod( + type="terminal", id="vibe-setup", name="Register your API Key", description="Register your API Key inside Mistral Vibe", @@ -228,9 +236,7 @@ class VibeAcpAgentLoop(AcpAgent): def _load_config(self) -> VibeConfig: try: - config = VibeConfig.load( - disabled_tools=["ask_user_question", "exit_plan_mode"] - ) + config = VibeConfig.load(disabled_tools=["ask_user_question"]) config.tool_paths.extend(self._get_acp_tool_overrides()) return config except MissingAPIKeyError as e: @@ -263,18 +269,22 @@ class VibeAcpAgentLoop(AcpAgent): config = self._load_config() - agent_loop = AgentLoop( - config=config, - agent_name=BuiltinAgentName.DEFAULT, - enable_streaming=True, - entrypoint_metadata=self._build_entrypoint_metadata(), - ) - agent_loop.agent_manager.register_agent(CHAT_AGENT) - # NOTE: For now, we pin session.id to agent_loop.session_id right after init time. - # 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 = await self._create_acp_session(agent_loop.session_id, agent_loop) + try: + agent_loop = AgentLoop( + config=config, + agent_name=BuiltinAgentName.DEFAULT, + enable_streaming=True, + entrypoint_metadata=self._build_entrypoint_metadata(), + ) + agent_loop.agent_manager.register_agent(CHAT_AGENT) + # NOTE: For now, we pin session.id to agent_loop.session_id right after init time. + # 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 = await self._create_acp_session(agent_loop.session_id, agent_loop) + except Exception as e: + raise ConfigurationError(str(e)) from e + agent_loop.emit_new_session_telemetry() modes_state, modes_config = make_mode_response( @@ -314,17 +324,15 @@ class VibeAcpAgentLoop(AcpAgent): session = self._get_session(session_id) def _handle_permission_selection( - option_id: str, tool_name: str + option_id: str, + tool_name: str, + required_permissions: list[RequiredPermission] | None, ) -> tuple[ApprovalResponse, str | None]: match option_id: case ToolOption.ALLOW_ONCE: return (ApprovalResponse.YES, None) case ToolOption.ALLOW_ALWAYS: - if tool_name not in session.agent_loop.config.tools: - session.agent_loop.config.tools[tool_name] = BaseToolConfig() - session.agent_loop.config.tools[ - tool_name - ].permission = ToolPermission.ALWAYS + session.agent_loop.approve_always(tool_name, required_permissions) return (ApprovalResponse.YES, None) case ToolOption.REJECT_ONCE: return ( @@ -335,19 +343,33 @@ class VibeAcpAgentLoop(AcpAgent): return (ApprovalResponse.NO, f"Unknown option: {option_id}") async def approval_callback( - tool_name: str, args: BaseModel, tool_call_id: str + tool_name: str, + args: BaseModel, + tool_call_id: str, + required_permissions: list | None = None, ) -> tuple[ApprovalResponse, str | None]: - # Create the tool call update - tool_call = ToolCallUpdate(tool_call_id=tool_call_id) - - response = await self.client.request_permission( - session_id=session_id, tool_call=tool_call, options=TOOL_OPTIONS + typed_permissions: list[RequiredPermission] | None = ( + [ + rp + for rp in required_permissions + if isinstance(rp, RequiredPermission) + ] + if required_permissions + else None + ) + + tool_call = ToolCallUpdate(tool_call_id=tool_call_id) + options = build_permission_options(typed_permissions) + + response = await self.client.request_permission( + session_id=session_id, tool_call=tool_call, options=options ) - # Parse the response using isinstance for proper type narrowing if response.outcome.outcome == "selected": outcome = cast(AllowedOutcome, response.outcome) - return _handle_permission_selection(outcome.option_id, tool_name) + return _handle_permission_selection( + outcome.option_id, tool_name, typed_permissions + ) else: return ( ApprovalResponse.NO, @@ -365,6 +387,39 @@ class VibeAcpAgentLoop(AcpAgent): raise SessionNotFoundError(session_id) return self.sessions[session_id] + def _build_usage(self, session: AcpSessionLoop) -> Usage: + stats = session.agent_loop.stats + return Usage( + input_tokens=stats.session_prompt_tokens, + output_tokens=stats.session_completion_tokens, + total_tokens=stats.session_total_llm_tokens, + ) + + def _build_usage_update(self, session: AcpSessionLoop) -> UsageUpdate: + stats = session.agent_loop.stats + active_model = session.agent_loop.config.get_active_model() + cost = ( + Cost(amount=stats.session_cost, currency="USD") + if stats.input_price_per_million > 0 or stats.output_price_per_million > 0 + else None + ) + return UsageUpdate( + session_update="usage_update", + used=stats.context_tokens, + size=active_model.auto_compact_threshold, + cost=cost, + ) + + def _send_usage_update(self, session: AcpSessionLoop) -> None: + async def _send() -> None: + try: + update = self._build_usage_update(session) + await self.client.session_update(session_id=session.id, update=update) + except Exception: + pass + + asyncio.create_task(_send()) + async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None: if not msg.tool_calls: return @@ -463,7 +518,7 @@ class VibeAcpAgentLoop(AcpAgent): VibeConfig.save_updates({"installed_agents": [*current, "lean"]}) new_config = VibeConfig.load( tool_paths=session.agent_loop.config.tool_paths, - disabled_tools=["ask_user_question", "exit_plan_mode"], + disabled_tools=["ask_user_question"], ) await session.agent_loop.reload_with_initial_messages( base_config=new_config @@ -492,7 +547,7 @@ class VibeAcpAgentLoop(AcpAgent): }) new_config = VibeConfig.load( tool_paths=session.agent_loop.config.tool_paths, - disabled_tools=["ask_user_question", "exit_plan_mode"], + disabled_tools=["ask_user_question"], ) await session.agent_loop.reload_with_initial_messages( base_config=new_config @@ -549,6 +604,7 @@ class VibeAcpAgentLoop(AcpAgent): session = await self._create_acp_session(session_id, agent_loop) await self._replay_conversation_history(session_id, non_system_messages) + self._send_usage_update(session) modes_state, modes_config = make_mode_response( list(agent_loop.agent_manager.available_agents.values()), @@ -589,7 +645,7 @@ class VibeAcpAgentLoop(AcpAgent): new_config = VibeConfig.load( tool_paths=session.agent_loop.config.tool_paths, - disabled_tools=["ask_user_question", "exit_plan_mode"], + disabled_tools=["ask_user_question"], ) await session.agent_loop.reload_with_initial_messages(base_config=new_config) @@ -675,7 +731,11 @@ class VibeAcpAgentLoop(AcpAgent): @override async def prompt( - self, prompt: list[ContentBlock], session_id: str, **kwargs: Any + self, + prompt: list[ContentBlock], + session_id: str, + message_id: str | None = None, + **kwargs: Any, ) -> PromptResponse: session = self._get_session(session_id) @@ -708,7 +768,10 @@ class VibeAcpAgentLoop(AcpAgent): await session.task except asyncio.CancelledError: - return PromptResponse(stop_reason="cancelled") + self._send_usage_update(session) + return PromptResponse( + stop_reason="cancelled", usage=self._build_usage(session) + ) except CoreRateLimitError as e: raise RateLimitError.from_core(e) from e @@ -722,7 +785,8 @@ class VibeAcpAgentLoop(AcpAgent): finally: session.task = None - return PromptResponse(stop_reason="end_turn") + self._send_usage_update(session) + return PromptResponse(stop_reason="end_turn", usage=self._build_usage(session)) def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str: text_prompt = "" @@ -841,6 +905,15 @@ class VibeAcpAgentLoop(AcpAgent): elif isinstance(event, CompactEndEvent): yield create_compact_end_session_update(event) + elif isinstance(event, AgentProfileChangedEvent): + pass + + @override + async def close_session( + self, session_id: str, **kwargs: Any + ) -> CloseSessionResponse | None: + raise NotImplementedMethodError("close_session") + @override async def cancel(self, session_id: str, **kwargs: Any) -> None: session = self._get_session(session_id) diff --git a/vibe/acp/entrypoint.py b/vibe/acp/entrypoint.py index db6a257..99d3f3b 100644 --- a/vibe/acp/entrypoint.py +++ b/vibe/acp/entrypoint.py @@ -8,7 +8,7 @@ import sys import tomli_w from vibe import __version__ -from vibe.core.config import VibeConfig +from vibe.core.config import MissingAPIKeyError, VibeConfig from vibe.core.config.harness_files import ( get_harness_files_manager, init_harness_files_manager, @@ -78,13 +78,23 @@ def main() -> None: init_harness_files_manager("user", "project") from vibe.acp.acp_agent_loop import run_acp_server + from vibe.core.config import VibeConfig, load_dotenv_values + from vibe.core.tracing import setup_tracing from vibe.setup.onboarding import run_onboarding + load_dotenv_values() bootstrap_config_files() args = parse_arguments() if args.setup: run_onboarding() sys.exit(0) + + try: + config = VibeConfig.load() + setup_tracing(config) + except MissingAPIKeyError: + pass # tracing disabled, but server can still handle the error properly in new_session + run_acp_server() diff --git a/vibe/acp/utils.py b/vibe/acp/utils.py index 64cf16d..a8527d3 100644 --- a/vibe/acp/utils.py +++ b/vibe/acp/utils.py @@ -9,7 +9,6 @@ from acp.schema import ( ContentToolCallContent, ModelInfo, PermissionOption, - SessionConfigOption, SessionConfigOptionSelect, SessionConfigSelectOption, SessionMode, @@ -23,6 +22,7 @@ from acp.schema import ( from vibe.core.agents.models import AgentProfile, AgentType from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings +from vibe.core.tools.permissions import RequiredPermission from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage from vibe.core.utils import compact_reduction_display @@ -45,7 +45,7 @@ TOOL_OPTIONS = [ ), PermissionOption( option_id=ToolOption.ALLOW_ALWAYS, - name="Allow always", + name="Allow for this session", kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS), ), PermissionOption( @@ -56,6 +56,44 @@ TOOL_OPTIONS = [ ] +def build_permission_options( + required_permissions: list[RequiredPermission] | None, +) -> list[PermissionOption]: + """Build ACP permission options, including granular labels when available.""" + if not required_permissions: + return TOOL_OPTIONS + + labels = ", ".join(rp.label for rp in required_permissions) + permissions_meta = [ + { + "scope": rp.scope, + "invocation_pattern": rp.invocation_pattern, + "session_pattern": rp.session_pattern, + "label": rp.label, + } + for rp in required_permissions + ] + + return [ + PermissionOption( + option_id=ToolOption.ALLOW_ONCE, + name="Allow once", + kind=cast(Literal["allow_once"], ToolOption.ALLOW_ONCE), + ), + PermissionOption( + option_id=ToolOption.ALLOW_ALWAYS, + name=f"Allow for this session: {labels}", + kind=cast(Literal["allow_always"], ToolOption.ALLOW_ALWAYS), + field_meta={"required_permissions": permissions_meta}, + ), + PermissionOption( + option_id=ToolOption.REJECT_ONCE, + name="Reject once", + kind=cast(Literal["reject_once"], ToolOption.REJECT_ONCE), + ), + ] + + def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool: return any( p.name == mode_name and p.agent_type == AgentType.AGENT for p in profiles @@ -64,7 +102,7 @@ def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool: def make_mode_response( profiles: list[AgentProfile], current_mode_id: str -) -> tuple[SessionModeState, SessionConfigOption]: +) -> tuple[SessionModeState, SessionConfigOptionSelect]: session_modes: list[SessionMode] = [] config_options: list[SessionConfigSelectOption] = [] @@ -89,22 +127,20 @@ def make_mode_response( state = SessionModeState( current_mode_id=current_mode_id, available_modes=session_modes ) - config = SessionConfigOption( - root=SessionConfigOptionSelect( - id="mode", - name="Session Mode", - current_value=current_mode_id, - category="mode", - type="select", - options=config_options, - ) + config = SessionConfigOptionSelect( + id="mode", + name="Session Mode", + current_value=current_mode_id, + category="mode", + type="select", + options=config_options, ) return state, config def make_model_response( models: list[ModelConfig], current_model_id: str -) -> tuple[SessionModelState, SessionConfigOption]: +) -> tuple[SessionModelState, SessionConfigOptionSelect]: model_infos: list[ModelInfo] = [] config_options: list[SessionConfigSelectOption] = [] @@ -119,15 +155,13 @@ def make_model_response( state = SessionModelState( current_model_id=current_model_id, available_models=model_infos ) - config_option = SessionConfigOption( - root=SessionConfigOptionSelect( - id="model", - name="Model", - current_value=current_model_id, - category="model", - type="select", - options=config_options, - ) + config_option = SessionConfigOptionSelect( + id="model", + name="Model", + current_value=current_model_id, + category="model", + type="select", + options=config_options, ) return state, config_option diff --git a/vibe/cli/cli.py b/vibe/cli/cli.py index e06aaa6..cce8fac 100644 --- a/vibe/cli/cli.py +++ b/vibe/cli/cli.py @@ -8,7 +8,7 @@ from rich import print as rprint import tomli_w from vibe import __version__ -from vibe.cli.textual_ui.app import run_textual_ui +from vibe.cli.textual_ui.app import StartupOptions, run_textual_ui from vibe.core.agent_loop import AgentLoop from vibe.core.agents.models import BuiltinAgentName from vibe.core.config import ( @@ -22,6 +22,7 @@ from vibe.core.logger import logger from vibe.core.paths import HISTORY_FILE from vibe.core.programmatic import run_programmatic from vibe.core.session.session_loader import SessionLoader +from vibe.core.tracing import setup_tracing from vibe.core.types import EntrypointMetadata, LLMMessage, OutputFormat, Role from vibe.core.utils import ConversationLimitException from vibe.setup.onboarding import run_onboarding @@ -104,6 +105,8 @@ def load_session( f"{config.session_logging.save_dir}[/]" ) sys.exit(1) + elif args.resume is True: + return None else: session_to_load = SessionLoader.find_session_by_id( args.resume, config.session_logging @@ -150,6 +153,7 @@ def run_cli(args: argparse.Namespace) -> None: try: initial_agent_name = get_initial_agent_name(args) config = load_config_or_exit() + setup_tracing(config) if args.enabled_tools: config.enabled_tools = args.enabled_tools @@ -206,8 +210,11 @@ def run_cli(args: argparse.Namespace) -> None: run_textual_ui( agent_loop=agent_loop, - initial_prompt=args.initial_prompt or stdin_prompt, - teleport_on_start=args.teleport, + startup=StartupOptions( + initial_prompt=args.initial_prompt or stdin_prompt, + teleport_on_start=args.teleport, + show_resume_picker=args.resume is True, + ), ) except (KeyboardInterrupt, EOFError): diff --git a/vibe/cli/commands.py b/vibe/cli/commands.py index 5c36f27..6a343c9 100644 --- a/vibe/cli/commands.py +++ b/vibe/cli/commands.py @@ -22,10 +22,15 @@ class CommandRegistry: handler="_show_help", ), "config": Command( - aliases=frozenset(["/config", "/model"]), + aliases=frozenset(["/config"]), description="Edit config settings", handler="_show_config", ), + "model": Command( + aliases=frozenset(["/model"]), + description="Select active model", + handler="_show_model", + ), "reload": Command( aliases=frozenset(["/reload"]), description="Reload configuration from disk", @@ -79,8 +84,8 @@ class CommandRegistry: ), "voice": Command( aliases=frozenset(["/voice"]), - description="Toggle voice mode on/off", - handler="_toggle_voice_mode", + description="Configure voice settings", + handler="_show_voice_settings", ), "leanstall": Command( aliases=frozenset(["/leanstall"]), diff --git a/vibe/cli/entrypoint.py b/vibe/cli/entrypoint.py index 0910a28..1d04a32 100644 --- a/vibe/cli/entrypoint.py +++ b/vibe/cli/entrypoint.py @@ -97,8 +97,11 @@ def parse_arguments() -> argparse.Namespace: ) continuation_group.add_argument( "--resume", + nargs="?", + const=True, + default=None, metavar="SESSION_ID", - help="Resume a specific session by its ID (supports partial matching)", + help="Resume a session. Without SESSION_ID, shows an interactive picker.", ) return parser.parse_args() diff --git a/vibe/cli/plan_offer/decide_plan_offer.py b/vibe/cli/plan_offer/decide_plan_offer.py index faefe78..458e58d 100644 --- a/vibe/cli/plan_offer/decide_plan_offer.py +++ b/vibe/cli/plan_offer/decide_plan_offer.py @@ -11,7 +11,8 @@ from vibe.cli.plan_offer.ports.whoami_gateway import ( WhoAmIPlanType, WhoAmIResponse, ) -from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, Backend, ProviderConfig +from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, ProviderConfig +from vibe.core.types import Backend logger = logging.getLogger(__name__) diff --git a/vibe/cli/terminal_setup.py b/vibe/cli/terminal_setup.py index b928652..2dd2642 100644 --- a/vibe/cli/terminal_setup.py +++ b/vibe/cli/terminal_setup.py @@ -9,6 +9,8 @@ import platform import subprocess from typing import Any, Literal +from vibe.core.utils.io import read_safe + class Terminal(Enum): VSCODE = "vscode" @@ -189,7 +191,7 @@ def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult: def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]: if keybindings_path.exists(): - content = keybindings_path.read_text() + content = read_safe(keybindings_path) return _parse_keybindings(content) keybindings_path.parent.mkdir(parents=True, exist_ok=True) return [] diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index 800d35d..1ef3c69 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +from collections.abc import Callable +from dataclasses import dataclass from enum import StrEnum, auto import gc import os @@ -40,12 +42,15 @@ from vibe.cli.textual_ui.notifications import ( NotificationPort, TextualNotificationAdapter, ) +from vibe.cli.textual_ui.session_exit import print_session_resume_message from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp from vibe.cli.textual_ui.widgets.banner.banner import Banner from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer +from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea from vibe.cli.textual_ui.widgets.compact import CompactMessage from vibe.cli.textual_ui.widgets.config_app import ConfigApp from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState +from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreRequested from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer from vibe.cli.textual_ui.widgets.messages import ( @@ -58,6 +63,8 @@ from vibe.cli.textual_ui.widgets.messages import ( WarningMessage, WhatsNewMessage, ) +from vibe.cli.textual_ui.widgets.model_picker import ModelPickerApp +from vibe.cli.textual_ui.widgets.narrator_status import NarratorState, NarratorStatus 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 @@ -65,6 +72,7 @@ from vibe.cli.textual_ui.widgets.question_app import QuestionApp from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage from vibe.cli.textual_ui.widgets.tools import ToolResultMessage +from vibe.cli.textual_ui.widgets.voice_app import VoiceApp from vibe.cli.textual_ui.windowing import ( HISTORY_RESUME_TAIL_MESSAGES, LOAD_MORE_BATCH_SIZE, @@ -76,6 +84,13 @@ from vibe.cli.textual_ui.windowing import ( should_resume_history, sync_backfill_state, ) +from vibe.cli.turn_summary import ( + NoopTurnSummary, + TurnSummaryPort, + TurnSummaryResult, + TurnSummaryTracker, + create_narrator_backend, +) from vibe.cli.update_notifier import ( FileSystemUpdateCacheRepository, PyPIUpdateGateway, @@ -92,9 +107,11 @@ from vibe.cli.voice_manager import VoiceManager, VoiceManagerPort from vibe.cli.voice_manager.voice_manager_port import TranscribeState from vibe.core.agent_loop import AgentLoop, TeleportError from vibe.core.agents import AgentProfile +from vibe.core.audio_player.audio_player import AudioPlayer +from vibe.core.audio_player.audio_player_port import AudioFormat from vibe.core.audio_recorder import AudioRecorder from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt -from vibe.core.config import Backend, VibeConfig +from vibe.core.config import VibeConfig from vibe.core.logger import logger from vibe.core.paths import HISTORY_FILE from vibe.core.session.session_loader import SessionLoader @@ -109,17 +126,20 @@ from vibe.core.teleport.types import ( TeleportSendingGithubTokenEvent, TeleportStartingWorkflowEvent, ) -from vibe.core.tools.base import ToolPermission from vibe.core.tools.builtins.ask_user_question import ( AskUserQuestionArgs, AskUserQuestionResult, Choice, Question, ) +from vibe.core.tools.permissions import RequiredPermission from vibe.core.transcribe import make_transcribe_client +from vibe.core.tts.factory import make_tts_client +from vibe.core.tts.tts_client_port import TTSClientPort from vibe.core.types import ( AgentStats, ApprovalResponse, + Backend, LLMMessage, RateLimitError, Role, @@ -129,6 +149,7 @@ from vibe.core.utils import ( get_user_cancellation_message, is_dangerous_directory, ) +from vibe.core.utils.io import read_safe class BottomApp(StrEnum): @@ -142,9 +163,11 @@ class BottomApp(StrEnum): Approval = auto() Config = auto() Input = auto() + ModelPicker = auto() ProxySetup = auto() Question = auto() SessionPicker = auto() + Voice = auto() class ChatScroll(VerticalScroll): @@ -154,9 +177,31 @@ class ChatScroll(VerticalScroll): def is_at_bottom(self) -> bool: return self.scroll_target_y >= (self.max_scroll_y - 3) - def _check_anchor(self) -> None: - if self._anchored and self._anchor_released and self.is_at_bottom: - self._anchor_released = False + _reanchor_pending: bool = False + _scrolling_down: bool = False + + def watch_scroll_y(self, old_value: float, new_value: float) -> None: + super().watch_scroll_y(old_value, new_value) + self._scrolling_down = new_value >= old_value + + def release_anchor(self) -> None: + super().release_anchor() + # Textual's MRO dispatch calls Widget._on_mouse_scroll_down AFTER + # our override, so any re-anchor we do gets immediately undone. + # Defer the re-check until all handlers for this event have finished. + if not self._reanchor_pending: + self._reanchor_pending = True + self.call_later(self._maybe_reanchor) + + def _maybe_reanchor(self) -> None: + self._reanchor_pending = False + if ( + self._anchored + and self._anchor_released + and self.is_at_bottom + and self._scrolling_down + ): + self.anchor() def update_node_styles(self, animate: bool = True) -> None: pass @@ -202,6 +247,13 @@ async def prune_oldest_children( return True +@dataclass(frozen=True, slots=True) +class StartupOptions: + initial_prompt: str | None = None + teleport_on_start: bool = False + show_resume_picker: bool = False + + class VibeApp(App): # noqa: PLR0904 ENABLE_COMMAND_PALETTE = False CSS_PATH = "app.tcss" @@ -225,8 +277,7 @@ class VibeApp(App): # noqa: PLR0904 def __init__( self, agent_loop: AgentLoop, - initial_prompt: str | None = None, - teleport_on_start: bool = False, + startup: StartupOptions | None = None, update_notifier: UpdateGateway | None = None, update_cache_repository: UpdateCacheRepository | None = None, current_version: str = CORE_VERSION, @@ -277,8 +328,10 @@ class VibeApp(App): # noqa: PLR0904 self._update_cache_repository = update_cache_repository self._current_version = current_version self._plan_offer_gateway = plan_offer_gateway - self._initial_prompt = initial_prompt - self._teleport_on_start = teleport_on_start and self.config.nuage_enabled + opts = startup or StartupOptions() + self._initial_prompt = opts.initial_prompt + self._teleport_on_start = opts.teleport_on_start and self.config.nuage_enabled + self._show_resume_picker = opts.show_resume_picker self._last_escape_time: float | None = None self._banner: Banner | None = None self._whats_new_message: WhatsNewMessage | None = None @@ -287,6 +340,12 @@ class VibeApp(App): # noqa: PLR0904 self._cached_loading_area: Widget | None = None self._switch_agent_generation = 0 self._plan_info: PlanInfo | None = None + self._turn_summary: TurnSummaryPort = self._make_turn_summary() + self._turn_summary_close_tasks: set[asyncio.Task[Any]] = set() + self._tts_client: TTSClientPort | None = self._make_tts_client() + self._audio_player = AudioPlayer() + self._speak_task: asyncio.Task[None] | None = None + self._cancel_summary: Callable[[], bool] | None = None @property def config(self) -> VibeConfig: @@ -299,7 +358,9 @@ class VibeApp(App): # noqa: PLR0904 yield VerticalGroup(id="messages") with Horizontal(id="loading-area"): + yield NarratorStatus() yield Static(id="loading-area-content") + yield FeedbackBar() with Static(id="bottom-app-container"): yield ChatInputContainer( @@ -326,10 +387,12 @@ class VibeApp(App): # noqa: PLR0904 self._cached_messages_area = self.query_one("#messages") self._cached_chat = self.query_one("#chat", ChatScroll) self._cached_loading_area = self.query_one("#loading-area-content") + self._feedback_bar = self.query_one(FeedbackBar) self.event_handler = EventHandler( mount_callback=self._mount_and_scroll, get_tools_collapsed=lambda: self._tools_collapsed, + on_profile_changed=self._on_profile_changed, ) self._chat_input_container = self.query_one(ChatInputContainer) @@ -359,7 +422,9 @@ class VibeApp(App): # noqa: PLR0904 self.call_after_refresh(self._refresh_banner) - if self._initial_prompt or self._teleport_on_start: + if self._show_resume_picker: + self.run_worker(self._show_session_picker(), exclusive=False) + elif self._initial_prompt or self._teleport_on_start: self.call_after_refresh(self._process_initial_prompt) gc.collect() @@ -424,9 +489,7 @@ class VibeApp(App): # noqa: PLR0904 async def on_approval_app_approval_granted_always_tool( self, message: ApprovalApp.ApprovalGrantedAlwaysTool ) -> None: - self._set_tool_permission_always( - message.tool_name, save_permanently=message.save_permanently - ) + self.agent_loop.approve_always(message.tool_name, message.required_permissions) if self._pending_approval and not self._pending_approval.done(): self._pending_approval.set_result((ApprovalResponse.YES, None)) @@ -453,22 +516,107 @@ class VibeApp(App): # noqa: PLR0904 result = AskUserQuestionResult(answers=[], cancelled=True) self._pending_question.set_result(result) + def on_chat_text_area_feedback_key_pressed( + self, message: ChatTextArea.FeedbackKeyPressed + ) -> None: + self._feedback_bar.handle_feedback_key(message.rating) + + def on_chat_text_area_non_feedback_key_pressed( + self, message: ChatTextArea.NonFeedbackKeyPressed + ) -> None: + self._feedback_bar.hide() + + def on_feedback_bar_feedback_given( + self, message: FeedbackBar.FeedbackGiven + ) -> None: + self.agent_loop.telemetry_client.send_user_rating_feedback( + rating=message.rating, model=self.config.active_model + ) + async def _remove_loading_widget(self) -> None: if self._loading_widget and self._loading_widget.parent: await self._loading_widget.remove() self._loading_widget = None + async def on_config_app_open_model_picker( + self, _message: ConfigApp.OpenModelPicker + ) -> None: + config_app = self.query_one(ConfigApp) + changes = config_app._convert_changes_for_save() + if changes: + VibeConfig.save_updates(changes) + await self._reload_config() + await self._switch_to_input_app() + await self._switch_to_model_picker_app() + async def on_config_app_config_closed( self, message: ConfigApp.ConfigClosed ) -> None: - if message.changes: - VibeConfig.save_updates(message.changes) + await self._handle_config_settings_closed(message.changes) + await self._switch_to_input_app() + + async def on_voice_app_config_closed(self, message: VoiceApp.ConfigClosed) -> None: + await self._handle_voice_settings_closed(message.changes) + await self._switch_to_input_app() + + async def _handle_config_settings_closed( + self, changes: dict[str, str | bool] + ) -> None: + if changes: + VibeConfig.save_updates(changes) await self._reload_config() else: await self._mount_and_scroll( UserCommandMessage("Configuration closed (no changes saved).") ) + async def _handle_voice_settings_closed( + self, changes: dict[str, str | bool] + ) -> None: + if not changes: + await self._mount_and_scroll( + UserCommandMessage("Voice settings closed (no changes saved).") + ) + return + + if "voice_mode_enabled" in changes: + current = self._voice_manager.is_enabled + desired = changes["voice_mode_enabled"] + if current != desired: + self._voice_manager.toggle_voice_mode() + self.agent_loop.telemetry_client.send_telemetry_event( + "vibe.voice_mode_toggled", {"enabled": desired} + ) + self.agent_loop.refresh_config() + if desired: + await self._mount_and_scroll( + UserCommandMessage( + "Voice mode enabled. Press ctrl+r to start recording." + ) + ) + else: + await self._mount_and_scroll( + UserCommandMessage("Voice mode disabled.") + ) + + non_voice_changes = { + k: v for k, v in changes.items() if k != "voice_mode_enabled" + } + if non_voice_changes: + VibeConfig.save_updates(non_voice_changes) + self.agent_loop.refresh_config() + self._sync_turn_summary() + + async def on_model_picker_app_model_selected( + self, message: ModelPickerApp.ModelSelected + ) -> None: + VibeConfig.save_updates({"active_model": message.alias}) + await self._reload_config() + await self._switch_to_input_app() + + async def on_model_picker_app_cancelled( + self, _event: ModelPickerApp.Cancelled + ) -> None: await self._switch_to_input_app() async def on_proxy_setup_app_proxy_setup_closed( @@ -507,13 +655,6 @@ class VibeApp(App): # noqa: PLR0904 for widget in children[:compact_index]: await widget.remove() - def _set_tool_permission_always( - self, tool_name: str, save_permanently: bool = False - ) -> None: - self.agent_loop.set_tool_permission( - tool_name, ToolPermission.ALWAYS, save_permanently - ) - 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): @@ -557,7 +698,7 @@ class VibeApp(App): # noqa: PLR0904 self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill") try: - skill_content = skill_info.skill_path.read_text(encoding="utf-8") + skill_content = read_safe(skill_info.skill_path) except OSError as e: await self._mount_and_scroll( ErrorMessage( @@ -612,6 +753,8 @@ class VibeApp(App): # noqa: PLR0904 user_message = UserMessage(message) await self._mount_and_scroll(user_message) + if self.agent_loop.telemetry_client.is_active(): + self._feedback_bar.maybe_show() if not self._agent_running: self._agent_task = asyncio.create_task( @@ -682,7 +825,11 @@ class VibeApp(App): # noqa: PLR0904 return tool in self.agent_loop.tool_manager.available_tools async def _approval_callback( - self, tool: str, args: BaseModel, tool_call_id: str + self, + tool: str, + args: BaseModel, + tool_call_id: str, + required_permissions: list[RequiredPermission] | None, ) -> tuple[ApprovalResponse, str | None]: # Auto-approve only if parent is in auto-approve mode AND tool is enabled # This ensures subagents respect the main agent's tool restrictions @@ -695,7 +842,7 @@ class VibeApp(App): # noqa: PLR0904 self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED) try: with paused_timer(self._loading_widget): - await self._switch_to_approval_app(tool, args) + await self._switch_to_approval_app(tool, args, required_permissions) result = await self._pending_approval return result finally: @@ -717,6 +864,12 @@ class VibeApp(App): # noqa: PLR0904 self._pending_question = None await self._switch_to_input_app() + async def _handle_turn_error(self) -> None: + if self._loading_widget and self._loading_widget.parent: + await self._loading_widget.remove() + if self.event_handler: + self.event_handler.stop_current_tool_call(success=False) + async def _handle_agent_loop_turn(self, prompt: str) -> None: self._agent_running = True @@ -730,7 +883,10 @@ class VibeApp(App): # noqa: PLR0904 try: rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd()) + self._cancel_speak() + self._turn_summary.start_turn(rendered_prompt) async for event in self.agent_loop.act(rendered_prompt): + self._turn_summary.track(event) if self.event_handler: await self.event_handler.handle_event( event, @@ -739,25 +895,29 @@ class VibeApp(App): # noqa: PLR0904 ) except asyncio.CancelledError: - if self._loading_widget and self._loading_widget.parent: - await self._loading_widget.remove() - if self.event_handler: - self.event_handler.stop_current_tool_call(success=False) + await self._handle_turn_error() + self._turn_summary.cancel_turn() raise except Exception as e: - if self._loading_widget and self._loading_widget.parent: - await self._loading_widget.remove() - if self.event_handler: - self.event_handler.stop_current_tool_call(success=False) + await self._handle_turn_error() message = str(e) if isinstance(e, RateLimitError): message = self._rate_limit_message() + self._turn_summary.set_error(message) await self._mount_and_scroll( ErrorMessage(message, collapsed=self._tools_collapsed) ) finally: + cancel_summary = self._turn_summary.end_turn() + if ( + cancel_summary is not None + and self.config.narrator_enabled + and self._tts_client is not None + ): + self._cancel_summary = cancel_summary + self.query_one(NarratorStatus).state = NarratorState.SUMMARIZING self._agent_running = False self._interrupt_requested = False self._agent_task = None @@ -933,6 +1093,12 @@ class VibeApp(App): # noqa: PLR0904 return await self._switch_to_config_app() + async def _show_model(self) -> None: + """Switch to the model picker in the bottom panel.""" + if self._current_bottom_app == BottomApp.ModelPicker: + return + await self._switch_to_model_picker_app() + async def _show_proxy_setup(self) -> None: if self._current_bottom_app == BottomApp.ProxySetup: return @@ -1045,6 +1211,7 @@ class VibeApp(App): # noqa: PLR0904 await self.agent_loop.reload_with_initial_messages(base_config=base_config) await self._resolve_plan() + self._sync_turn_summary() if self._banner: self._banner.set_state( @@ -1229,16 +1396,13 @@ class VibeApp(App): # noqa: PLR0904 lambda: self.config, audio_recorder=AudioRecorder(), transcribe_client=transcribe_client, + telemetry_client=self.agent_loop.telemetry_client, ) - async def _toggle_voice_mode(self) -> None: - result = self._voice_manager.toggle_voice_mode() - self.agent_loop.refresh_config() - if result.enabled: - msg = "Voice mode enabled. Press ctrl+r to start recording." - else: - msg = "Voice mode disabled." - await self._mount_and_scroll(UserCommandMessage(msg)) + async def _show_voice_settings(self) -> None: + if self._current_bottom_app == BottomApp.Voice: + return + await self._switch_to_voice_app() async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None: bottom_container = self.query_one("#bottom-app-container") @@ -1249,6 +1413,8 @@ class VibeApp(App): # noqa: PLR0904 self._chat_input_container.display = False self._chat_input_container.disabled = True + self._feedback_bar.hide() + self._current_bottom_app = BottomApp[type(widget).__name__.removesuffix("App")] await bottom_container.mount(widget) @@ -1263,6 +1429,23 @@ 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_voice_app(self) -> None: + if self._current_bottom_app == BottomApp.Voice: + return + + await self._mount_and_scroll(UserCommandMessage("Voice settings opened...")) + await self._switch_from_input(VoiceApp(self.config)) + + async def _switch_to_model_picker_app(self) -> None: + if self._current_bottom_app == BottomApp.ModelPicker: + return + + model_aliases = [m.alias for m in self.config.models] + current_model = str(self.config.active_model) + await self._switch_from_input( + ModelPickerApp(model_aliases=model_aliases, current_model=current_model) + ) + async def _switch_to_proxy_setup_app(self) -> None: if self._current_bottom_app == BottomApp.ProxySetup: return @@ -1271,10 +1454,16 @@ class VibeApp(App): # noqa: PLR0904 await self._switch_from_input(ProxySetupApp()) async def _switch_to_approval_app( - self, tool_name: str, tool_args: BaseModel + self, + tool_name: str, + tool_args: BaseModel, + required_permissions: list[RequiredPermission] | None = None, ) -> None: approval_app = ApprovalApp( - tool_name=tool_name, tool_args=tool_args, config=self.config + tool_name=tool_name, + tool_args=tool_args, + config=self.config, + required_permissions=required_permissions, ) await self._switch_from_input(approval_app, scroll=True) @@ -1306,6 +1495,8 @@ class VibeApp(App): # noqa: PLR0904 self.query_one(ChatInputContainer).focus_input() case BottomApp.Config: self.query_one(ConfigApp).focus() + case BottomApp.ModelPicker: + self.query_one(ModelPickerApp).focus() case BottomApp.ProxySetup: self.query_one(ProxySetupApp).focus() case BottomApp.Approval: @@ -1314,6 +1505,8 @@ class VibeApp(App): # noqa: PLR0904 self.query_one(QuestionApp).focus() case BottomApp.SessionPicker: self.query_one(SessionPickerApp).focus() + case BottomApp.Voice: + self.query_one(VoiceApp).focus() case app: assert_never(app) except Exception: @@ -1327,6 +1520,14 @@ class VibeApp(App): # noqa: PLR0904 pass self._last_escape_time = None + def _handle_voice_app_escape(self) -> None: + try: + voice_app = self.query_one(VoiceApp) + voice_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) @@ -1345,6 +1546,14 @@ class VibeApp(App): # noqa: PLR0904 self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question") self._last_escape_time = None + def _handle_model_picker_app_escape(self) -> None: + try: + model_picker = self.query_one(ModelPickerApp) + model_picker.post_message(ModelPickerApp.Cancelled()) + except Exception: + pass + self._last_escape_time = None + def _handle_session_picker_app_escape(self) -> None: try: session_picker = self.query_one(SessionPickerApp) @@ -1376,6 +1585,10 @@ class VibeApp(App): # noqa: PLR0904 self._handle_config_app_escape() return + if self._current_bottom_app == BottomApp.Voice: + self._handle_voice_app_escape() + return + if self._current_bottom_app == BottomApp.ProxySetup: try: proxy_setup_app = self.query_one(ProxySetupApp) @@ -1393,6 +1606,10 @@ class VibeApp(App): # noqa: PLR0904 self._handle_question_app_escape() return + if self._current_bottom_app == BottomApp.ModelPicker: + self._handle_model_picker_app_escape() + return + if self._current_bottom_app == BottomApp.SessionPicker: self._handle_session_picker_app_escape() return @@ -1405,6 +1622,11 @@ class VibeApp(App): # noqa: PLR0904 self._handle_input_app_escape() return + narrator_status = self.query_one(NarratorStatus) + if self._audio_player.is_playing or narrator_status.state != NarratorState.IDLE: + self._cancel_speak() + return + if self._agent_running: self._handle_agent_running_escape() @@ -1469,6 +1691,10 @@ class VibeApp(App): # noqa: PLR0904 def _refresh_profile_widgets(self) -> None: self._update_profile_widgets(self.agent_loop.agent_profile) + def _on_profile_changed(self) -> None: + self._refresh_profile_widgets() + self._refresh_banner() + def _refresh_banner(self) -> None: if self._banner: self._banner.set_state( @@ -1730,31 +1956,99 @@ class VibeApp(App): # noqa: PLR0904 # force a full layout refresh so the UI isn't garbled. self.refresh(layout=True) + def _make_turn_summary(self) -> TurnSummaryPort: + if not self.config.narrator_enabled: + return NoopTurnSummary() + result = create_narrator_backend(self.config) + if result is None: + return NoopTurnSummary() + backend, model = result + return TurnSummaryTracker( + backend=backend, model=model, on_summary=self._on_turn_summary + ) -def _print_session_resume_message(session_id: str | None) -> None: - if not session_id: - return + def _on_turn_summary(self, result: TurnSummaryResult) -> None: + self._cancel_summary = None + if result.generation != self._turn_summary.generation: + self._set_narrator_state(NarratorState.IDLE) + return + if result.summary is None: + self._set_narrator_state(NarratorState.IDLE) + return + if self._tts_client is not None: + self._speak_task = asyncio.create_task(self._speak_summary(result.summary)) + else: + self._set_narrator_state(NarratorState.IDLE) - print() - print("To continue this session, run: vibe --continue") - print(f"Or: vibe --resume {session_id}") + async def _speak_summary(self, text: str) -> None: + if self._tts_client is None: + return + try: + loop = asyncio.get_running_loop() + tts_result = await self._tts_client.speak(text) + self._set_narrator_state(NarratorState.SPEAKING) + self._audio_player.play( + tts_result.audio_data, + AudioFormat.WAV, + on_finished=lambda: loop.call_soon_threadsafe( + self._set_narrator_state, NarratorState.IDLE + ), + ) + except Exception: + logger.warning("TTS speak failed", exc_info=True) + self._set_narrator_state(NarratorState.IDLE) + + def _cancel_speak(self) -> None: + if self._cancel_summary is not None: + self._cancel_summary() + self._cancel_summary = None + if self._speak_task is not None and not self._speak_task.done(): + self._speak_task.cancel() + self._speak_task = None + self._audio_player.stop() + self._set_narrator_state(NarratorState.IDLE) + + def _set_narrator_state(self, state: NarratorState) -> None: + self.query_one(NarratorStatus).state = state + + def _make_tts_client(self) -> TTSClientPort | None: + if not self.config.narrator_enabled: + return None + try: + model = self.config.get_active_tts_model() + provider = self.config.get_tts_provider_for_model(model) + return make_tts_client(provider, model) + except (ValueError, KeyError) as exc: + logger.error("Failed to initialize TTS client", exc_info=exc) + return None + + def _sync_turn_summary(self) -> None: + self._cancel_speak() + task = asyncio.create_task(self._turn_summary.close()) + self._turn_summary_close_tasks.add(task) + task.add_done_callback(self._turn_summary_close_tasks.discard) + self._turn_summary = self._make_turn_summary() + + old_tts = self._tts_client + self._tts_client = self._make_tts_client() + if old_tts is not None: + close_task = asyncio.create_task(old_tts.close()) + self._turn_summary_close_tasks.add(close_task) + close_task.add_done_callback(self._turn_summary_close_tasks.discard) def run_textual_ui( - agent_loop: AgentLoop, - initial_prompt: str | None = None, - teleport_on_start: bool = False, + agent_loop: AgentLoop, startup: StartupOptions | None = None ) -> None: update_notifier = PyPIUpdateGateway(project_name="mistral-vibe") update_cache_repository = FileSystemUpdateCacheRepository() plan_offer_gateway = HttpWhoAmIGateway() app = VibeApp( agent_loop=agent_loop, - initial_prompt=initial_prompt, - teleport_on_start=teleport_on_start, + startup=startup, update_notifier=update_notifier, update_cache_repository=update_cache_repository, plan_offer_gateway=plan_offer_gateway, ) session_id = app.run() - _print_session_resume_message(session_id) + print_session_resume_message(session_id, agent_loop.stats) diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss index 4dfc57f..c03d50a 100644 --- a/vibe/cli/textual_ui/app.tcss +++ b/vibe/cli/textual_ui/app.tcss @@ -160,7 +160,7 @@ Markdown { color: ansi_default; .code_inline { - color: ansi_yellow; + color: ansi_green; background: transparent; text-style: bold; } @@ -625,7 +625,8 @@ StatusMessage { .loading-hint { width: auto; height: auto; - color: $foreground; + color: ansi_bright_black; + } .history-load-more-message { @@ -655,7 +656,7 @@ StatusMessage { } -#config-app { +#config-app, #voice-app { width: 100%; height: auto; background: transparent; @@ -664,7 +665,7 @@ StatusMessage { margin: 0; } -#config-content { +#config-content, #voice-content { width: 100%; height: auto; } @@ -675,41 +676,14 @@ StatusMessage { color: ansi_blue; } -.settings-option { - height: auto; - color: ansi_default; +#config-options { + width: 100%; + max-height: 50vh; + border: none; } -.settings-cursor-selected { - color: ansi_blue; - text-style: bold; -} - -.settings-label-selected { - color: ansi_default; - text-style: bold; -} - -.settings-value-toggle-on-selected { - color: ansi_green; - text-style: bold; -} - -.settings-value-toggle-on-unselected { - color: ansi_green; -} - -.settings-value-toggle-off { - color: ansi_bright_black; -} - -.settings-value-cycle-selected { - color: ansi_blue; - text-style: bold; -} - -.settings-value-cycle-unselected { - color: ansi_blue; +#config-options:focus { + border: none; } .settings-help { @@ -953,6 +927,14 @@ ContextProgress { color: ansi_bright_black; } +NarratorStatus { + width: auto; + height: auto; + background: transparent; + padding: 0; + margin: 0 0 0 1; +} + #banner-container { align: left middle; padding: 0 1 0 0; @@ -1083,3 +1065,54 @@ ContextProgress { color: ansi_bright_black; margin-top: 1; } + +#modelpicker-app { + width: 100%; + height: auto; + background: transparent; + border: solid ansi_bright_black; + padding: 0 1; + margin: 0; +} + +#modelpicker-content { + width: 100%; + height: auto; +} + +.modelpicker-title { + height: auto; + text-style: bold; + color: ansi_blue; +} + +#modelpicker-options { + width: 100%; + max-height: 50vh; + text-wrap: nowrap; + text-overflow: ellipsis; + border: none; +} + +#modelpicker-options:focus { + border: none; +} + +.modelpicker-help { + width: 100%; + height: auto; + color: ansi_bright_black; + margin-top: 1; +} + +FeedbackBar { + width: auto; + height: auto; + margin-left: 1; +} + +#feedback-text { + width: auto; + height: auto; + color: ansi_default; +} diff --git a/vibe/cli/textual_ui/constants.py b/vibe/cli/textual_ui/constants.py new file mode 100644 index 0000000..a33d330 --- /dev/null +++ b/vibe/cli/textual_ui/constants.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from enum import StrEnum + + +class MistralColors(StrEnum): + RED = "#E10500" + ORANGE_DARK = "#FA500F" + ORANGE = "#FF8205" + ORANGE_LIGHT = "#FFAF00" + YELLOW = "#FFD800" diff --git a/vibe/cli/textual_ui/external_editor.py b/vibe/cli/textual_ui/external_editor.py index b4bbbe6..da1f297 100644 --- a/vibe/cli/textual_ui/external_editor.py +++ b/vibe/cli/textual_ui/external_editor.py @@ -6,6 +6,8 @@ import shlex import subprocess import tempfile +from vibe.core.utils.io import read_safe + class ExternalEditor: """Handles opening an external editor to edit prompt content.""" @@ -24,7 +26,7 @@ class ExternalEditor: parts = shlex.split(editor) subprocess.run([*parts, filepath], check=True) - content = Path(filepath).read_text().rstrip() + content = read_safe(Path(filepath)).rstrip() return content if content != initial_content else None except (OSError, subprocess.CalledProcessError): return diff --git a/vibe/cli/textual_ui/handlers/event_handler.py b/vibe/cli/textual_ui/handlers/event_handler.py index 0bdbff8..50ecf82 100644 --- a/vibe/cli/textual_ui/handlers/event_handler.py +++ b/vibe/cli/textual_ui/handlers/event_handler.py @@ -9,6 +9,7 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage from vibe.core.tools.ui import ToolUIDataAdapter from vibe.core.types import ( + AgentProfileChangedEvent, AssistantEvent, BaseEvent, CompactEndEvent, @@ -27,10 +28,14 @@ if TYPE_CHECKING: class EventHandler: def __init__( - self, mount_callback: Callable, get_tools_collapsed: Callable[[], bool] + self, + mount_callback: Callable, + get_tools_collapsed: Callable[[], bool], + on_profile_changed: Callable[[], None] | None = None, ) -> None: self.mount_callback = mount_callback self.get_tools_collapsed = get_tools_collapsed + self.on_profile_changed = on_profile_changed self.tool_calls: dict[str, ToolCallMessage] = {} self.current_compact: CompactMessage | None = None self.current_streaming_message: AssistantMessage | None = None @@ -62,6 +67,9 @@ class EventHandler: case CompactEndEvent(): await self.finalize_streaming() await self._handle_compact_end(event) + case AgentProfileChangedEvent(): + if self.on_profile_changed: + self.on_profile_changed() case UserMessageEvent(): await self.finalize_streaming() case _: diff --git a/vibe/cli/textual_ui/session_exit.py b/vibe/cli/textual_ui/session_exit.py new file mode 100644 index 0000000..2ed1f94 --- /dev/null +++ b/vibe/cli/textual_ui/session_exit.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from rich import print as rprint + +from vibe.core.types import AgentStats + + +def format_session_usage(stats: AgentStats) -> str: + return ( + "Total tokens used this session: " + f"input={stats.session_prompt_tokens:,} " + f"output={stats.session_completion_tokens:,} " + f"(total={stats.session_total_llm_tokens:,})" + ) + + +def print_session_resume_message(session_id: str | None, stats: AgentStats) -> None: + if not session_id: + return + + print() + print(format_session_usage(stats)) + print() + rprint("To continue this session, run: [bold dark_orange]vibe --continue[/]") + rprint(f"Or: [bold dark_orange]vibe --resume {session_id}[/]") diff --git a/vibe/cli/textual_ui/widgets/approval_app.py b/vibe/cli/textual_ui/widgets/approval_app.py index 041fee0..0fcfc6d 100644 --- a/vibe/cli/textual_ui/widgets/approval_app.py +++ b/vibe/cli/textual_ui/widgets/approval_app.py @@ -13,6 +13,7 @@ from textual.widgets import Static from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.tool_widgets import get_approval_widget from vibe.core.config import VibeConfig +from vibe.core.tools.permissions import RequiredPermission class ApprovalApp(Container): @@ -38,12 +39,15 @@ class ApprovalApp(Container): class ApprovalGrantedAlwaysTool(Message): def __init__( - self, tool_name: str, tool_args: BaseModel, save_permanently: bool + self, + tool_name: str, + tool_args: BaseModel, + required_permissions: list[RequiredPermission], ) -> None: super().__init__() self.tool_name = tool_name self.tool_args = tool_args - self.save_permanently = save_permanently + self.required_permissions = required_permissions class ApprovalRejected(Message): def __init__(self, tool_name: str, tool_args: BaseModel) -> None: @@ -52,12 +56,17 @@ class ApprovalApp(Container): self.tool_args = tool_args def __init__( - self, tool_name: str, tool_args: BaseModel, config: VibeConfig + self, + tool_name: str, + tool_args: BaseModel, + config: VibeConfig, + required_permissions: list[RequiredPermission] | None = None, ) -> None: super().__init__(id="approval-app") self.tool_name = tool_name self.tool_args = tool_args self.config = config + self.required_permissions = required_permissions or [] self.selected_option = 0 self.content_container: Vertical | None = None self.title_widget: Static | None = None @@ -104,9 +113,15 @@ class ApprovalApp(Container): await self.tool_info_container.mount(approval_widget) def _update_options(self) -> None: + if self.required_permissions: + labels = ", ".join(rp.label for rp in self.required_permissions) + always_text = f"Yes and always allow for this session: {labels}" + else: + always_text = f"Yes and always allow {self.tool_name} for this session" + options = [ ("Yes", "yes"), - (f"Yes and always allow {self.tool_name} for this session", "yes"), + (always_text, "yes"), ("No and tell the agent what to do instead", "no"), ] @@ -178,7 +193,7 @@ class ApprovalApp(Container): self.ApprovalGrantedAlwaysTool( tool_name=self.tool_name, tool_args=self.tool_args, - save_permanently=False, + required_permissions=self.required_permissions, ) ) case 2: diff --git a/vibe/cli/textual_ui/widgets/chat_input/text_area.py b/vibe/cli/textual_ui/widgets/chat_input/text_area.py index ba2b453..835a260 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/text_area.py +++ b/vibe/cli/textual_ui/widgets/chat_input/text_area.py @@ -161,6 +161,16 @@ class ChatTextArea(TextArea): self.post_message(self.HistoryNext()) return True + class FeedbackKeyPressed(Message): + def __init__(self, rating: int) -> None: + self.rating = rating + super().__init__() + + class NonFeedbackKeyPressed(Message): + pass + + feedback_active: bool = False + async def _handle_voice_key(self, event: events.Key) -> bool: if not self._voice_manager: return False @@ -193,6 +203,15 @@ class ChatTextArea(TextArea): self._mark_cursor_moved_if_needed() + if self.feedback_active: + if event.character in {"1", "2", "3"}: + event.prevent_default() + event.stop() + self.post_message(self.FeedbackKeyPressed(int(event.character))) + return + if event.character is not None: + self.post_message(self.NonFeedbackKeyPressed()) + manager = self._completion_manager if manager: match manager.on_key( diff --git a/vibe/cli/textual_ui/widgets/config_app.py b/vibe/cli/textual_ui/widgets/config_app.py index c92427e..536f77f 100644 --- a/vibe/cli/textual_ui/widgets/config_app.py +++ b/vibe/cli/textual_ui/widgets/config_app.py @@ -1,13 +1,15 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar, TypedDict +from typing import TYPE_CHECKING, ClassVar -from textual import events +from rich.text import Text from textual.app import ComposeResult from textual.binding import Binding, BindingType from textual.containers import Container, Vertical +from textual.events import DescendantBlur from textual.message import Message -from textual.widgets import Static +from textual.widgets import OptionList +from textual.widgets.option_list import Option from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic @@ -15,22 +17,13 @@ if TYPE_CHECKING: from vibe.core.config import VibeConfig -class SettingDefinition(TypedDict): - key: str - label: str - type: str - options: list[str] - - class ConfigApp(Container): - can_focus = True - can_focus_children = False + """Settings panel with navigatable option picker.""" + + can_focus_children = True BINDINGS: ClassVar[list[BindingType]] = [ - Binding("up", "move_up", "Up", show=False), - Binding("down", "move_down", "Down", show=False), - Binding("space", "toggle_setting", "Toggle", show=False), - Binding("enter", "cycle", "Next", show=False), + Binding("escape", "close", "Close", show=False) ] class SettingChanged(Message): @@ -44,122 +37,92 @@ class ConfigApp(Container): super().__init__() self.changes = changes + class OpenModelPicker(Message): + pass + def __init__(self, config: VibeConfig) -> None: super().__init__(id="config-app") self.config = config - self.selected_index = 0 self.changes: dict[str, str] = {} - - self.settings: list[SettingDefinition] = [ - { - "key": "active_model", - "label": "Model", - "type": "cycle", - "options": [m.alias for m in self.config.models], - }, - { - "key": "autocopy_to_clipboard", - "label": "Auto-copy", - "type": "cycle", - "options": ["On", "Off"], - }, - { - "key": "file_watcher_for_autocomplete", - "label": "Autocomplete watcher (may delay first autocompletion)", - "type": "cycle", - "options": ["On", "Off"], - }, + self._toggle_settings: list[tuple[str, str]] = [ + ("autocopy_to_clipboard", "Auto-copy"), + ( + "file_watcher_for_autocomplete", + "Autocomplete watcher (may delay first autocompletion)", + ), ] - self.title_widget: Static | None = None - self.setting_widgets: list[Static] = [] - self.help_widget: Static | None = None + def _get_current_model(self) -> str: + return str(getattr(self.config, "active_model", "")) - def compose(self) -> ComposeResult: - with Vertical(id="config-content"): - self.title_widget = NoMarkupStatic("Settings", classes="settings-title") - yield self.title_widget - - yield NoMarkupStatic("") - - for _ in self.settings: - widget = NoMarkupStatic("", classes="settings-option") - self.setting_widgets.append(widget) - yield widget - - yield NoMarkupStatic("") - - self.help_widget = NoMarkupStatic( - "↑↓ navigate Space/Enter toggle ESC exit", classes="settings-help" - ) - yield self.help_widget - - def on_mount(self) -> None: - self._update_display() - self.focus() - - def _get_display_value(self, setting: SettingDefinition) -> str: - key = setting["key"] + def _get_toggle_value(self, key: str) -> str: if key in self.changes: return self.changes[key] - raw_value = getattr(self.config, key, "") - if isinstance(raw_value, bool): - return "On" if raw_value else "Off" - return str(raw_value) + raw = getattr(self.config, key, False) + if isinstance(raw, bool): + return "On" if raw else "Off" + return str(raw) - def _update_display(self) -> None: - for i, (setting, widget) in enumerate( - zip(self.settings, self.setting_widgets, strict=True) - ): - is_selected = i == self.selected_index - cursor = "› " if is_selected else " " + def _model_prompt(self) -> Text: + text = Text(no_wrap=True) + text.append("Model: ") + text.append(self._get_current_model(), style="bold") + return text - label: str = setting["label"] - value: str = self._get_display_value(setting) + def _toggle_prompt(self, key: str, label: str) -> Text: + value = self._get_toggle_value(key) + text = Text(no_wrap=True) + text.append(f"{label}: ") + if value == "On": + text.append("On", style="green bold") + else: + text.append("Off", style="dim") + return text - text = f"{cursor}{label}: {value}" + def compose(self) -> ComposeResult: + options: list[Option] = [Option(self._model_prompt(), id="action:active_model")] + for key, label in self._toggle_settings: + options.append(Option(self._toggle_prompt(key, label), id=f"toggle:{key}")) - widget.update(text) + with Vertical(id="config-content"): + yield NoMarkupStatic("Settings", classes="settings-title") + yield NoMarkupStatic("") + yield OptionList(*options, id="config-options") + yield NoMarkupStatic("") + yield NoMarkupStatic( + "↑↓ Navigate Enter Select/Toggle Esc Exit", classes="settings-help" + ) - widget.remove_class("settings-cursor-selected") - widget.remove_class("settings-value-cycle-selected") - widget.remove_class("settings-value-cycle-unselected") + def on_mount(self) -> None: + self.query_one(OptionList).focus() - if is_selected: - widget.add_class("settings-value-cycle-selected") - else: - widget.add_class("settings-value-cycle-unselected") + def on_descendant_blur(self, _event: DescendantBlur) -> None: + self.query_one(OptionList).focus() - def action_move_up(self) -> None: - self.selected_index = (self.selected_index - 1) % len(self.settings) - self._update_display() + def _refresh_options(self) -> None: + option_list = self.query_one(OptionList) + option_list.replace_option_prompt("action:active_model", self._model_prompt()) + for key, label in self._toggle_settings: + option_list.replace_option_prompt( + f"toggle:{key}", self._toggle_prompt(key, label) + ) - def action_move_down(self) -> None: - self.selected_index = (self.selected_index + 1) % len(self.settings) - self._update_display() + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + option_id = event.option.id + if not option_id: + return - def action_toggle_setting(self) -> None: - setting = self.settings[self.selected_index] - key: str = setting["key"] - current: str = self._get_display_value(setting) + if option_id == "action:active_model": + self.post_message(self.OpenModelPicker()) + return - options: list[str] = setting["options"] - new_value = "" - try: - current_idx = options.index(current) - next_idx = (current_idx + 1) % len(options) - new_value = options[next_idx] - except (ValueError, IndexError): - new_value = options[0] if options else current - - self.changes[key] = new_value - - self.post_message(self.SettingChanged(key=key, value=new_value)) - - self._update_display() - - def action_cycle(self) -> None: - self.action_toggle_setting() + if option_id.startswith("toggle:"): + key = option_id.removeprefix("toggle:") + current = self._get_toggle_value(key) + new_value = "Off" if current == "On" else "On" + self.changes[key] = new_value + self.post_message(self.SettingChanged(key=key, value=new_value)) + self._refresh_options() def _convert_changes_for_save(self) -> dict[str, str | bool]: result: dict[str, str | bool] = {} @@ -172,6 +135,3 @@ class ConfigApp(Container): def action_close(self) -> None: self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save())) - - def on_blur(self, event: events.Blur) -> None: - self.call_after_refresh(self.focus) diff --git a/vibe/cli/textual_ui/widgets/feedback_bar.py b/vibe/cli/textual_ui/widgets/feedback_bar.py new file mode 100644 index 0000000..ba5e4a4 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/feedback_bar.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import random + +from rich.text import Text +from textual.app import ComposeResult +from textual.message import Message +from textual.widget import Widget +from textual.widgets import Static + +from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea + +FEEDBACK_PROBABILITY = 0.02 +THANK_YOU_DURATION = 2.0 + + +class FeedbackBar(Widget): + class FeedbackGiven(Message): + def __init__(self, rating: int) -> None: + super().__init__() + self.rating = rating + + @staticmethod + def _prompt_text() -> Text: + text = Text() + text.append("How is Vibe doing so far? ") + text.append("1", style="blue") + text.append(": good ") + text.append("2", style="blue") + text.append(": fine ") + text.append("3", style="blue") + text.append(": bad") + return text + + def compose(self) -> ComposeResult: + yield Static(self._prompt_text(), id="feedback-text") + + def on_mount(self) -> None: + self.display = False + + def maybe_show(self) -> None: + if self.display: + return + if random.random() <= FEEDBACK_PROBABILITY: + self._set_active(True) + + def hide(self) -> None: + if self.display: + self._set_active(False) + + def handle_feedback_key(self, rating: int) -> None: + try: + self.app.query_one(ChatTextArea).feedback_active = False + except Exception: + pass + self.query_one("#feedback-text", Static).update( + Text("Thank you for your feedback!") + ) + self.post_message(self.FeedbackGiven(rating)) + self.set_timer(THANK_YOU_DURATION, lambda: self._set_active(False)) + + def _set_active(self, active: bool) -> None: + if active: + self.query_one("#feedback-text", Static).update(self._prompt_text()) + self.display = active + try: + self.app.query_one(ChatTextArea).feedback_active = active + except Exception: + pass diff --git a/vibe/cli/textual_ui/widgets/loading.py b/vibe/cli/textual_ui/widgets/loading.py index 3e1aad0..5cbbde2 100644 --- a/vibe/cli/textual_ui/widgets/loading.py +++ b/vibe/cli/textual_ui/widgets/loading.py @@ -11,6 +11,7 @@ from textual.app import ComposeResult from textual.containers import Horizontal from textual.widgets import Static +from vibe.cli.textual_ui.constants import MistralColors from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType @@ -28,7 +29,13 @@ def _format_elapsed(seconds: int) -> str: class LoadingWidget(SpinnerMixin, Static): - TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500") + TARGET_COLORS = ( + MistralColors.YELLOW, + MistralColors.ORANGE_LIGHT, + MistralColors.ORANGE, + MistralColors.ORANGE_DARK, + MistralColors.RED, + ) SPINNER_TYPE = SpinnerType.SNAKE EASTER_EGGS: ClassVar[list[str]] = [ diff --git a/vibe/cli/textual_ui/widgets/model_picker.py b/vibe/cli/textual_ui/widgets/model_picker.py new file mode 100644 index 0000000..89cb7b2 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/model_picker.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from typing import Any, ClassVar + +from rich.text import Text +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 OptionList +from textual.widgets.option_list import Option + +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic + + +def _build_option_text(alias: str, is_current: bool) -> Text: + text = Text(no_wrap=True) + marker = "› " if is_current else " " + style = "bold" if is_current else "" + text.append(marker, style="green" if is_current else "") + text.append(alias, style=style) + return text + + +class ModelPickerApp(Container): + """Model picker bottom app for selecting the active model.""" + + can_focus_children = True + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("escape", "cancel", "Cancel", show=False) + ] + + class ModelSelected(Message): + def __init__(self, alias: str) -> None: + self.alias = alias + super().__init__() + + class Cancelled(Message): + pass + + def __init__( + self, model_aliases: list[str], current_model: str, **kwargs: Any + ) -> None: + super().__init__(id="modelpicker-app", **kwargs) + self._model_aliases = model_aliases + self._current_model = current_model + + def compose(self) -> ComposeResult: + options = [ + Option(_build_option_text(alias, alias == self._current_model), id=alias) + for alias in self._model_aliases + ] + with Vertical(id="modelpicker-content"): + yield NoMarkupStatic("Select Model", classes="modelpicker-title") + yield OptionList(*options, id="modelpicker-options") + yield NoMarkupStatic( + "↑↓ Navigate Enter Select Esc Cancel", classes="modelpicker-help" + ) + + def on_mount(self) -> None: + option_list = self.query_one(OptionList) + # Pre-select the current model + for i, alias in enumerate(self._model_aliases): + if alias == self._current_model: + option_list.highlighted = i + break + option_list.focus() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + if event.option.id: + self.post_message(self.ModelSelected(event.option.id)) + + def action_cancel(self) -> None: + self.post_message(self.Cancelled()) diff --git a/vibe/cli/textual_ui/widgets/narrator_status.py b/vibe/cli/textual_ui/widgets/narrator_status.py new file mode 100644 index 0000000..4b22ed9 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/narrator_status.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from enum import StrEnum, auto +from typing import Any + +from textual.reactive import reactive +from textual.timer import Timer +from textual.widgets import Static + +SHRINK_FRAMES = "█▇▆▅▄▃▂▁" +BAR_FRAMES = ["▂▅▇", "▃▆▅", "▅▃▇", "▇▂▅", "▅▇▃", "▃▅▆"] +ANIMATION_INTERVAL = 0.15 + + +class NarratorState(StrEnum): + IDLE = auto() + SUMMARIZING = auto() + SPEAKING = auto() + + +class NarratorStatus(Static): + state = reactive(NarratorState.IDLE) + + def __init__(self, **kwargs: Any) -> None: + super().__init__("", **kwargs) + self._timer: Timer | None = None + self._frame: int = 0 + + def watch_state(self, new_state: NarratorState) -> None: + self._stop_timer() + match new_state: + case NarratorState.IDLE: + self.update("") + case NarratorState.SUMMARIZING | NarratorState.SPEAKING: + self._frame = 0 + self._tick() + self._timer = self.set_interval(ANIMATION_INTERVAL, self._tick) + + def _tick(self) -> None: + match self.state: + case NarratorState.SUMMARIZING: + char = SHRINK_FRAMES[self._frame % len(SHRINK_FRAMES)] + self.update( + f"[bold orange]{char}[/bold orange] summarizing [dim]esc to stop[/dim]" + ) + case NarratorState.SPEAKING: + bars = BAR_FRAMES[self._frame % len(BAR_FRAMES)] + self.update( + f"[bold orange]{bars}[/bold orange] speaking [dim]esc to stop[/dim]" + ) + self._frame += 1 + + def _stop_timer(self) -> None: + if self._timer is not None: + self._timer.stop() + self._timer = None diff --git a/vibe/cli/textual_ui/widgets/voice_app.py b/vibe/cli/textual_ui/widgets/voice_app.py new file mode 100644 index 0000000..18090f7 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/voice_app.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, TypedDict + +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 Static + +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic + +if TYPE_CHECKING: + from vibe.core.config import VibeConfig + + +class SettingDefinition(TypedDict): + key: str + label: str + type: str + options: list[str] + + +class VoiceApp(Container): + can_focus = True + can_focus_children = False + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("up", "move_up", "Up", show=False), + Binding("down", "move_down", "Down", show=False), + Binding("space", "toggle_setting", "Toggle", show=False), + Binding("enter", "cycle", "Next", show=False), + ] + + class SettingChanged(Message): + def __init__(self, key: str, value: str) -> None: + super().__init__() + self.key = key + self.value = value + + class ConfigClosed(Message): + def __init__(self, changes: dict[str, str | bool]) -> None: + super().__init__() + self.changes = changes + + def __init__(self, config: VibeConfig) -> None: + super().__init__(id="voice-app") + self.config = config + self.selected_index = 0 + self.changes: dict[str, str] = {} + + self.settings: list[SettingDefinition] = [ + { + "key": "voice_mode_enabled", + "label": "Voice mode", + "type": "cycle", + "options": ["On", "Off"], + }, + { + "key": "narrator_enabled", + "label": "Narrator (experimental)", + "type": "cycle", + "options": ["On", "Off"], + }, + ] + + self.title_widget: Static | None = None + self.setting_widgets: list[Static] = [] + self.help_widget: Static | None = None + + def compose(self) -> ComposeResult: + with Vertical(id="voice-content"): + self.title_widget = NoMarkupStatic( + "Voice Settings", classes="settings-title" + ) + yield self.title_widget + + yield NoMarkupStatic("") + + for _ in self.settings: + widget = NoMarkupStatic("", classes="settings-option") + self.setting_widgets.append(widget) + yield widget + + yield NoMarkupStatic("") + + self.help_widget = NoMarkupStatic( + "↑↓ navigate Space/Enter toggle ESC exit", classes="settings-help" + ) + yield self.help_widget + + def on_mount(self) -> None: + self._update_display() + self.focus() + + def _get_display_value(self, setting: SettingDefinition) -> str: + key = setting["key"] + if key in self.changes: + return self.changes[key] + raw_value = getattr(self.config, key, "") + if isinstance(raw_value, bool): + return "On" if raw_value else "Off" + return str(raw_value) + + def _update_display(self) -> None: + for i, (setting, widget) in enumerate( + zip(self.settings, self.setting_widgets, strict=True) + ): + is_selected = i == self.selected_index + cursor = "› " if is_selected else " " + + label: str = setting["label"] + value: str = self._get_display_value(setting) + + text = f"{cursor}{label}: {value}" + + widget.update(text) + + widget.remove_class("settings-cursor-selected") + widget.remove_class("settings-value-cycle-selected") + widget.remove_class("settings-value-cycle-unselected") + + if is_selected: + widget.add_class("settings-value-cycle-selected") + else: + widget.add_class("settings-value-cycle-unselected") + + def action_move_up(self) -> None: + self.selected_index = (self.selected_index - 1) % len(self.settings) + self._update_display() + + def action_move_down(self) -> None: + self.selected_index = (self.selected_index + 1) % len(self.settings) + self._update_display() + + def action_toggle_setting(self) -> None: + setting = self.settings[self.selected_index] + key: str = setting["key"] + current: str = self._get_display_value(setting) + + options: list[str] = setting["options"] + new_value = "" + try: + current_idx = options.index(current) + next_idx = (current_idx + 1) % len(options) + new_value = options[next_idx] + except (ValueError, IndexError): + new_value = options[0] if options else current + + self.changes[key] = new_value + + self.post_message(self.SettingChanged(key=key, value=new_value)) + + self._update_display() + + def action_cycle(self) -> None: + self.action_toggle_setting() + + def _convert_changes_for_save(self) -> dict[str, str | bool]: + result: dict[str, str | bool] = {} + for key, value in self.changes.items(): + if value in {"On", "Off"}: + result[key] = value == "On" + else: + result[key] = value + return result + + def action_close(self) -> None: + self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save())) + + def on_blur(self, event: events.Blur) -> None: + self.call_after_refresh(self.focus) diff --git a/vibe/cli/textual_ui/widgets/vscode_compat.py b/vibe/cli/textual_ui/widgets/vscode_compat.py index 10460e7..1e9c1a7 100644 --- a/vibe/cli/textual_ui/widgets/vscode_compat.py +++ b/vibe/cli/textual_ui/widgets/vscode_compat.py @@ -14,7 +14,7 @@ def patch_vscode_space(event: events.Key) -> None: silently drop the keystroke because there is no printable character. Assigning ``event.character = " "`` restores normal behaviour. """ - if event.key == "space" and event.character is None: + if event.key in {"space", "shift+space"} and event.character is None: event.character = " " diff --git a/vibe/cli/turn_summary/__init__.py b/vibe/cli/turn_summary/__init__.py new file mode 100644 index 0000000..819fbc8 --- /dev/null +++ b/vibe/cli/turn_summary/__init__.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from vibe.cli.turn_summary.noop import NoopTurnSummary +from vibe.cli.turn_summary.port import ( + TurnSummaryData, + TurnSummaryPort, + TurnSummaryResult, +) +from vibe.cli.turn_summary.tracker import TurnSummaryTracker +from vibe.cli.turn_summary.utils import NARRATOR_MODEL, create_narrator_backend + +__all__ = [ + "NARRATOR_MODEL", + "NoopTurnSummary", + "TurnSummaryData", + "TurnSummaryPort", + "TurnSummaryResult", + "TurnSummaryTracker", + "create_narrator_backend", +] diff --git a/vibe/cli/turn_summary/noop.py b/vibe/cli/turn_summary/noop.py new file mode 100644 index 0000000..cd1a41a --- /dev/null +++ b/vibe/cli/turn_summary/noop.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from collections.abc import Callable + +from vibe.cli.turn_summary.port import TurnSummaryPort +from vibe.core.types import BaseEvent + + +class NoopTurnSummary(TurnSummaryPort): + @property + def generation(self) -> int: + return 0 + + def start_turn(self, user_message: str) -> None: + pass + + def track(self, event: BaseEvent) -> None: + pass + + def set_error(self, message: str) -> None: + pass + + def cancel_turn(self) -> None: + pass + + def end_turn(self) -> Callable[[], bool] | None: + return None + + async def close(self) -> None: + pass diff --git a/vibe/cli/turn_summary/port.py b/vibe/cli/turn_summary/port.py new file mode 100644 index 0000000..295dd2b --- /dev/null +++ b/vibe/cli/turn_summary/port.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Callable + +from pydantic import BaseModel, Field + +from vibe.core.types import BaseEvent + + +class TurnSummaryData(BaseModel): + user_message: str + assistant_fragments: list[str] = Field(default_factory=list) + error: str | None = None + + +class TurnSummaryResult(BaseModel): + generation: int + summary: str | None + + +class TurnSummaryPort(ABC): + @property + @abstractmethod + def generation(self) -> int: ... + + @abstractmethod + def start_turn(self, user_message: str) -> None: ... + + @abstractmethod + def track(self, event: BaseEvent) -> None: ... + + @abstractmethod + def set_error(self, message: str) -> None: ... + + @abstractmethod + def cancel_turn(self) -> None: ... + + @abstractmethod + def end_turn(self) -> Callable[[], bool] | None: ... + + @abstractmethod + async def close(self) -> None: ... diff --git a/vibe/cli/turn_summary/tracker.py b/vibe/cli/turn_summary/tracker.py new file mode 100644 index 0000000..b47a014 --- /dev/null +++ b/vibe/cli/turn_summary/tracker.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import Any + +from vibe.cli.turn_summary.port import ( + TurnSummaryData, + TurnSummaryPort, + TurnSummaryResult, +) +from vibe.core.config import ModelConfig +from vibe.core.llm.types import BackendLike +from vibe.core.logger import logger +from vibe.core.prompts import UtilityPrompt +from vibe.core.types import AssistantEvent, BaseEvent, LLMMessage, Role + + +class TurnSummaryTracker(TurnSummaryPort): + def __init__( + self, + backend: BackendLike, + model: ModelConfig, + on_summary: Callable[[TurnSummaryResult], None], + max_tokens: int = 512, + ) -> None: + self._backend = backend + self._model = model + self._on_summary = on_summary + self._max_tokens = max_tokens + self._tasks: set[asyncio.Task[Any]] = set() + self._data: TurnSummaryData | None = None + self._generation: int = 0 + + @property + def generation(self) -> int: + return self._generation + + def start_turn(self, user_message: str) -> None: + self._generation += 1 + self._data = TurnSummaryData(user_message=user_message) + + def track(self, event: BaseEvent) -> None: + if self._data is None: + return + match event: + case AssistantEvent(content=c) if c: + self._data.assistant_fragments.append(c) + + def set_error(self, message: str) -> None: + if self._data is not None: + self._data.error = message + + def cancel_turn(self) -> None: + self._data = None + + def end_turn(self) -> Callable[[], bool] | None: + if self._data is None: + return None + gen = self._generation + task = asyncio.create_task(self._generate_summary(self._data, gen)) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + self._data = None + return task.cancel + + async def close(self) -> None: + for task in self._tasks: + task.cancel() + await asyncio.gather(*self._tasks, return_exceptions=True) + self._tasks.clear() + + async def _generate_summary(self, data: TurnSummaryData, gen: int) -> None: + try: + prompt_text = UtilityPrompt.TURN_SUMMARY.read() + + sections: list[str] = [] + sections.append(f"## User Request\n{data.user_message}") + + full_text = "".join(data.assistant_fragments) + if full_text: + sections.append(f"## Assistant Response\n{full_text}") + + if data.error: + sections.append(f"## Error\n{data.error}") + + extraction_text = "\n\n".join(sections) + + summary_messages = [ + LLMMessage(role=Role.system, content=prompt_text), + LLMMessage(role=Role.user, content=extraction_text), + ] + + result = await self._backend.complete( + model=self._model, + messages=summary_messages, + temperature=0.0, + tools=None, + tool_choice=None, + max_tokens=self._max_tokens, + extra_headers={}, + metadata={}, + ) + + summary = result.message.content or "" + self._on_summary(TurnSummaryResult(generation=gen, summary=summary)) + except Exception: + logger.warning("Turn summary generation failed", exc_info=True) + self._on_summary(TurnSummaryResult(generation=gen, summary=None)) diff --git a/vibe/cli/turn_summary/utils.py b/vibe/cli/turn_summary/utils.py new file mode 100644 index 0000000..e5a8391 --- /dev/null +++ b/vibe/cli/turn_summary/utils.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import os + +from vibe.core.config import ModelConfig, VibeConfig +from vibe.core.llm.backend.factory import BACKEND_FACTORY +from vibe.core.llm.types import BackendLike + +NARRATOR_MODEL = ModelConfig( + name="mistral-vibe-cli-fast", + provider="mistral", + alias="mistral-small", + input_price=0.1, + output_price=0.3, +) + + +def create_narrator_backend( + config: VibeConfig, +) -> tuple[BackendLike, ModelConfig] | None: + try: + provider = config.get_provider_for_model(NARRATOR_MODEL) + except ValueError: + return None + if provider.api_key_env_var and not os.getenv(provider.api_key_env_var): + return None + backend = BACKEND_FACTORY[provider.backend]( + provider=provider, timeout=config.api_timeout + ) + return backend, NARRATOR_MODEL diff --git a/vibe/cli/update_notifier/whats_new.py b/vibe/cli/update_notifier/whats_new.py index 61ce16a..d1ece83 100644 --- a/vibe/cli/update_notifier/whats_new.py +++ b/vibe/cli/update_notifier/whats_new.py @@ -7,6 +7,7 @@ from vibe.cli.update_notifier.ports.update_cache_repository import ( UpdateCache, UpdateCacheRepository, ) +from vibe.core.utils.io import read_safe async def should_show_whats_new( @@ -23,7 +24,7 @@ def load_whats_new_content() -> str | None: if not whats_new_file.exists(): return None try: - content = whats_new_file.read_text(encoding="utf-8").strip() + content = read_safe(whats_new_file).strip() return content if content else None except OSError: return None diff --git a/vibe/cli/voice_manager/telemetry.py b/vibe/cli/voice_manager/telemetry.py new file mode 100644 index 0000000..1d39ab8 --- /dev/null +++ b/vibe/cli/voice_manager/telemetry.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +import time + + +@dataclass +class TranscriptionTrackingState: + recording_id: str = "" + start_time: float = field(default_factory=time.monotonic) + accumulated_transcript_length: int = 0 + last_recording_duration_ms: float | None = None + + def reset(self) -> None: + self.recording_id = "" + self.start_time = time.monotonic() + self.accumulated_transcript_length = 0 + self.last_recording_duration_ms = None + + def set_recording_id(self, recording_id: str) -> None: + self.recording_id = recording_id + + def record_text(self, text: str) -> None: + self.accumulated_transcript_length += len(text) + + def elapsed_ms(self) -> float: + return (time.monotonic() - self.start_time) * 1000 + + def set_recording_duration(self, duration_s: float) -> None: + self.last_recording_duration_ms = duration_s * 1000 diff --git a/vibe/cli/voice_manager/voice_manager.py b/vibe/cli/voice_manager/voice_manager.py index 49287e2..df0678e 100644 --- a/vibe/cli/voice_manager/voice_manager.py +++ b/vibe/cli/voice_manager/voice_manager.py @@ -1,15 +1,14 @@ from __future__ import annotations -from asyncio import CancelledError, Task, create_task, wait_for -from collections.abc import Callable +from asyncio import CancelledError, create_task, wait_for +from typing import TYPE_CHECKING +from vibe.cli.voice_manager.telemetry import TranscriptionTrackingState from vibe.cli.voice_manager.voice_manager_port import ( RecordingStartError, TranscribeState, - VoiceManagerListener, VoiceToggleResult, ) -from vibe.core.audio_recorder import AudioRecorderPort from vibe.core.audio_recorder.audio_recorder_port import ( AlreadyRecordingError, AudioBackendUnavailableError, @@ -19,13 +18,21 @@ from vibe.core.audio_recorder.audio_recorder_port import ( from vibe.core.config import VibeConfig from vibe.core.logger import logger from vibe.core.transcribe.transcribe_client_port import ( - TranscribeClientPort, TranscribeDone, TranscribeError, TranscribeSessionCreated, TranscribeTextDelta, ) +if TYPE_CHECKING: + from asyncio import Task + from collections.abc import Callable + + from vibe.cli.voice_manager.voice_manager_port import VoiceManagerListener + from vibe.core.audio_recorder import AudioRecorderPort + from vibe.core.telemetry.send import TelemetryClient + from vibe.core.transcribe.transcribe_client_port import TranscribeClientPort + TRANSCRIPTION_DRAIN_TIMEOUT = 10.0 @@ -35,13 +42,16 @@ class VoiceManager: config_getter: Callable[[], VibeConfig], audio_recorder: AudioRecorderPort, transcribe_client: TranscribeClientPort | None, + telemetry_client: TelemetryClient | None = None, ) -> None: self._config_getter = config_getter self._audio_recorder = audio_recorder self._transcribe_client = transcribe_client + self._telemetry_client = telemetry_client self._transcribe_state = TranscribeState.IDLE self._transcribe_task: Task[None] | None = None self._listeners: list[VoiceManagerListener] = [] + self._tracking = TranscriptionTrackingState() @property def is_enabled(self) -> bool: @@ -91,6 +101,7 @@ class VoiceManager: except NoAudioInputDeviceError: raise RecordingStartError("No audio input device found") + self._tracking.reset() self._set_state(TranscribeState.RECORDING) self._transcribe_task = create_task(self._run_transcription()) @@ -101,7 +112,8 @@ class VoiceManager: if should_flush_queue: self._set_state(TranscribeState.FLUSHING) - self._audio_recorder.stop(wait_for_queue_drained=should_flush_queue) + recording = self._audio_recorder.stop(wait_for_queue_drained=should_flush_queue) + self._tracking.set_recording_duration(recording.duration) if self._transcribe_task is not None: try: @@ -111,6 +123,7 @@ class VoiceManager: except TimeoutError: logger.warning("Transcription task timed out, cancelling") self._transcribe_task.cancel() + self._on_audio_transcription_error("Transcription timed out") except CancelledError: pass self._transcribe_task = None @@ -129,6 +142,7 @@ class VoiceManager: self._transcribe_task = None self._set_state(TranscribeState.IDLE) + self._on_audio_transcription_cancel() def add_listener(self, listener: VoiceManagerListener) -> None: if listener not in self._listeners: @@ -150,6 +164,7 @@ class VoiceManager: async for event in self._transcribe_client.transcribe(audio_stream): match event: case TranscribeTextDelta(text=text): + self._tracking.record_text(text) for listener in self._listeners: try: listener.on_transcribe_text(text) @@ -158,22 +173,80 @@ class VoiceManager: "Listener raised during transcribe text", exc_info=True, ) - case TranscribeDone(): - pass case TranscribeError(message=msg): raise RuntimeError(msg) - case TranscribeSessionCreated(): + case TranscribeSessionCreated(request_id=request_id): + self._tracking.set_recording_id(request_id) + self._on_audio_transcription_start() + case TranscribeDone(): pass + if self._transcribe_state != TranscribeState.IDLE: self._set_state(TranscribeState.IDLE) + + self._on_audio_transcription_done() except CancelledError: raise except Exception as exc: logger.error("Transcription failed", exc_info=exc) self._audio_recorder.cancel() + if self._transcribe_state != TranscribeState.IDLE: self._set_state(TranscribeState.IDLE) + self._on_audio_transcription_error(str(exc)) + + def _on_audio_transcription_start(self) -> None: + if not self._telemetry_client: + return + self._telemetry_client.send_telemetry_event( + "vibe.audio.transcription.start", + {"recording_id": self._tracking.recording_id}, + ) + + def _on_audio_transcription_cancel(self) -> None: + if not self._telemetry_client: + return + self._telemetry_client.send_telemetry_event( + "vibe.audio.transcription.cancel_recording", + { + "recording_id": self._tracking.recording_id, + "recording_duration_ms": self._tracking.elapsed_ms(), + }, + ) + + def _on_audio_transcription_done(self) -> None: + if not self._telemetry_client: + return + transcription_duration_ms = self._tracking.elapsed_ms() + recording_duration_ms = ( + self._tracking.last_recording_duration_ms + if self._tracking.last_recording_duration_ms is not None + else transcription_duration_ms + ) + self._telemetry_client.send_telemetry_event( + "vibe.audio.transcription.done", + { + "recording_id": self._tracking.recording_id, + "transcript_length": self._tracking.accumulated_transcript_length, + "transcription_duration_ms": transcription_duration_ms, + "recording_duration_ms": recording_duration_ms, + }, + ) + + def _on_audio_transcription_error(self, error_message: str) -> None: + if not self._telemetry_client: + return + self._telemetry_client.send_telemetry_event( + "vibe.audio.transcription.error", + { + "recording_id": self._tracking.recording_id, + "error_message": error_message, + "transcription_duration_ms": self._tracking.elapsed_ms(), + "recording_duration_ms": self._tracking.last_recording_duration_ms, + }, + ) + def _set_state(self, state: TranscribeState) -> None: if self._transcribe_state == state: return diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index 42a5da6..5147fec 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -5,19 +5,19 @@ from collections.abc import AsyncGenerator, Callable, Generator import contextlib 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, Any, Literal from uuid import uuid4 +from opentelemetry import trace from pydantic import BaseModel from vibe.cli.terminal_setup import detect_terminal from vibe.core.agents.manager import AgentManager from vibe.core.agents.models import AgentProfile, BuiltinAgentName -from vibe.core.config import Backend, ModelConfig, ProviderConfig, VibeConfig +from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig from vibe.core.llm.backend.factory import BACKEND_FACTORY from vibe.core.llm.exceptions import BackendError from vibe.core.llm.format import ( @@ -52,7 +52,6 @@ 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, InvokeContext, ToolError, ToolPermission, @@ -61,8 +60,16 @@ from vibe.core.tools.base import ( from vibe.core.tools.manager import ToolManager from vibe.core.tools.mcp import MCPRegistry from vibe.core.tools.mcp_sampling import MCPSamplingHandler +from vibe.core.tools.permissions import ( + ApprovedRule, + PermissionContext, + RequiredPermission, +) +from vibe.core.tools.utils import wildcard_match +from vibe.core.tracing import agent_span, set_tool_result, tool_span from vibe.core.trusted_folders import has_agents_md_file from vibe.core.types import ( + AgentProfileChangedEvent, AgentStats, ApprovalCallback, ApprovalResponse, @@ -198,6 +205,9 @@ class AgentLoop: self.entrypoint_metadata = entrypoint_metadata self.session_id = str(uuid4()) self._current_user_message_id: str | None = None + self._is_user_prompt_call: bool = False + + self._session_rules: list[ApprovedRule] = [] self.telemetry_client = TelemetryClient( config_getter=lambda: self.config, session_id_getter=lambda: self.session_id @@ -238,11 +248,43 @@ class AgentLoop: }) if tool_name not in self.config.tools: - self.config.tools[tool_name] = BaseToolConfig() + self.config.tools[tool_name] = {} - self.config.tools[tool_name].permission = permission + self.config.tools[tool_name]["permission"] = permission.value self.tool_manager.invalidate_tool(tool_name) + def add_session_rule(self, rule: ApprovedRule) -> None: + self._session_rules.append(rule) + + def _is_permission_covered(self, tool_name: str, rp: RequiredPermission) -> bool: + return any( + rule.tool_name == tool_name + and rule.scope == rp.scope + and wildcard_match(rp.invocation_pattern, rule.session_pattern) + for rule in self._session_rules + ) + + def approve_always( + self, + tool_name: str, + required_permissions: list[RequiredPermission] | None, + save_permanently: bool = False, + ) -> None: + """Handle 'Allow Always' approval: add session rules or set tool-level permission.""" + if required_permissions: + for rp in required_permissions: + self.add_session_rule( + ApprovedRule( + tool_name=tool_name, + scope=rp.scope, + session_pattern=rp.session_pattern, + ) + ) + else: + self.set_tool_permission( + tool_name, ToolPermission.ALWAYS, save_permanently=save_permanently + ) + def refresh_config(self) -> None: self._base_config = VibeConfig.load() self.agent_manager.invalidate_config() @@ -253,6 +295,14 @@ class AgentLoop: if self.entrypoint_metadata else "unknown" ) + client_name = ( + self.entrypoint_metadata.client_name if self.entrypoint_metadata else None + ) + client_version = ( + self.entrypoint_metadata.client_version + if self.entrypoint_metadata + else 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) @@ -268,6 +318,8 @@ class AgentLoop: nb_mcp_servers=nb_mcp_servers, nb_models=nb_models, entrypoint=entrypoint, + client_name=client_name, + client_version=client_version, terminal_emulator=terminal_emulator, ) @@ -288,8 +340,13 @@ class AgentLoop: async def act(self, msg: str) -> AsyncGenerator[BaseEvent]: self._clean_message_history() - async for event in self._conversation_loop(msg): - yield event + try: + model_name = self.config.get_active_model().name + except ValueError: + model_name = None + async with agent_span(model=model_name, session_id=self.session_id): + async for event in self._conversation_loop(msg): + yield event @property def teleport_service(self) -> TeleportService: @@ -429,20 +486,22 @@ class AgentLoop: def _build_metadata(self) -> dict[str, str]: base = self.entrypoint_metadata.model_dump() if self.entrypoint_metadata else {} - return base | {"session_id": self.session_id} + metadata = base | { + "session_id": self.session_id, + "is_user_prompt": "true" if self._is_user_prompt_call else "false", + "call_type": ( + "main_call" if self._is_user_prompt_call else "secondary_call" + ), + } + if self._current_user_message_id is not None: + metadata["message_id"] = self._current_user_message_id + return metadata 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]: @@ -458,7 +517,9 @@ class AgentLoop: try: should_break_loop = False + first_llm_turn = True while not should_break_loop: + self._is_user_prompt_call = False result = await self.middleware_pipeline.run_before_turn( self._get_context() ) @@ -470,11 +531,15 @@ class AgentLoop: self.stats.steps += 1 user_cancelled = False + if first_llm_turn: + self._is_user_prompt_call = True + first_llm_turn = False async for event in self._perform_llm_turn(): if is_user_cancellation_event(event): user_cancelled = True yield event await self._save_messages() + self._is_user_prompt_call = False last_message = self.messages[-1] should_break_loop = last_message.role != Role.tool @@ -502,8 +567,11 @@ class AgentLoop: if not resolved.tool_calls and not resolved.failed_calls: return + profile_before = self.agent_profile.name async for event in self._handle_tool_calls(resolved): yield event + if self.agent_profile.name != profile_before: + yield AgentProfileChangedEvent(agent_name=self.agent_profile.name) def _build_tool_call_events( self, tool_calls: list[ToolCall] | None, emitted_ids: set[str] @@ -578,12 +646,23 @@ class AgentLoop: async def _process_one_tool_call( self, tool_call: ResolvedToolCall + ) -> AsyncGenerator[ToolResultEvent | ToolStreamEvent]: + async with tool_span( + tool_name=tool_call.tool_name, + call_id=tool_call.call_id, + arguments=tool_call.validated_args.model_dump_json(), + ) as span: + async for event in self._execute_tool_call(span, tool_call): + yield event + + async def _execute_tool_call( + self, span: trace.Span, 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 self._tool_failure_event(tool_call, error_msg) + yield self._tool_failure_event(tool_call, error_msg, span=span) return decision: ToolDecision | None = None @@ -607,7 +686,9 @@ class AgentLoop: cancelled=f"<{CANCELLATION_TAG}>" in skip_reason, tool_call_id=tool_call.call_id, ) - self._handle_tool_response(tool_call, skip_reason, "skipped", decision) + self._handle_tool_response( + tool_call, skip_reason, "skipped", decision, span=span + ) return self.stats.tool_calls_agreed += 1 @@ -625,6 +706,7 @@ class AgentLoop: sampling_callback=self._sampling_handler, plan_file_path=self._plan_session.plan_file_path, switch_agent_callback=self.switch_agent, + skill_manager=self.skill_manager, ), **tool_call.args_dict, ): @@ -643,7 +725,7 @@ class AgentLoop: if extra: text += "\n\n" + extra self._handle_tool_response( - tool_call, text, "success", decision, result_dict + tool_call, text, "success", decision, result_dict, span=span ) yield ToolResultEvent( tool_name=tool_call.tool_name, @@ -660,7 +742,9 @@ class AgentLoop: get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED) ) self.stats.tool_calls_failed += 1 - yield self._tool_failure_event(tool_call, cancel, decision, cancelled=True) + yield self._tool_failure_event( + tool_call, cancel, decision, cancelled=True, span=span + ) raise except Exception as exc: @@ -670,7 +754,7 @@ class AgentLoop: self.stats.tool_calls_rejected += 1 else: self.stats.tool_calls_failed += 1 - yield self._tool_failure_event(tool_call, error_msg, decision) + yield self._tool_failure_event(tool_call, error_msg, decision, span=span) async def _handle_tool_calls( self, resolved: ResolvedMessage @@ -751,6 +835,7 @@ class AgentLoop: status: Literal["success", "failure", "skipped"], decision: ToolDecision | None = None, result: dict[str, Any] | None = None, + span: trace.Span | None = None, ) -> None: self.messages.append( LLMMessage.model_validate( @@ -758,6 +843,8 @@ class AgentLoop: ) ) + if span is not None: + set_tool_result(span, text) self.telemetry_client.send_tool_call_finished( tool_call=tool_call, agent_profile_name=self.agent_profile.name, @@ -772,9 +859,10 @@ class AgentLoop: error_msg: str, decision: ToolDecision | None = None, cancelled: bool = False, + span: trace.Span | None = None, ) -> ToolResultEvent: """Create a ToolResultEvent for a failed tool and record the failure.""" - self._handle_tool_response(tool_call, error_msg, "failure", decision) + self._handle_tool_response(tool_call, error_msg, "failure", decision, span=span) return ToolResultEvent( tool_name=tool_call.tool_name, tool_class=tool_call.tool_class, @@ -812,6 +900,9 @@ class AgentLoop: ) self._update_stats(usage=result.usage, time_seconds=end_time - start_time) + if result.correlation_id: + self.telemetry_client.last_correlation_id = result.correlation_id + processed_message = self.format_handler.process_api_response_message( result.message ) @@ -848,6 +939,8 @@ class AgentLoop: max_tokens=max_tokens, metadata=self._build_metadata(), ): + if chunk.correlation_id: + self.telemetry_client.last_correlation_id = chunk.correlation_id processed_message = self.format_handler.process_api_response_message( chunk.message ) @@ -893,12 +986,13 @@ class AgentLoop: ) tool_name = tool.get_name() - effective = ( - tool.resolve_permission(args) - or self.tool_manager.get_tool_config(tool_name).permission - ) + ctx = tool.resolve_permission(args) - match effective: + if ctx is None: + config_perm = self.tool_manager.get_tool_config(tool_name).permission + ctx = PermissionContext(permission=config_perm) + + match ctx.permission: case ToolPermission.ALWAYS: return ToolDecision( verdict=ToolExecutionResponse.EXECUTE, @@ -911,10 +1005,26 @@ class AgentLoop: feedback=f"Tool '{tool_name}' is permanently disabled", ) case _: - return await self._ask_approval(tool_name, args, tool_call_id) + uncovered = [ + rp + for rp in ctx.required_permissions + if not self._is_permission_covered(tool_name, rp) + ] + if ctx.required_permissions and not uncovered: + return ToolDecision( + verdict=ToolExecutionResponse.EXECUTE, + approval_type=ToolPermission.ALWAYS, + ) + return await self._ask_approval( + tool_name, args, tool_call_id, uncovered + ) async def _ask_approval( - self, tool_name: str, args: BaseModel, tool_call_id: str + self, + tool_name: str, + args: BaseModel, + tool_call_id: str, + required_permissions: list[RequiredPermission], ) -> ToolDecision: if not self.approval_callback: return ToolDecision( @@ -922,7 +1032,9 @@ class AgentLoop: approval_type=ToolPermission.ASK, feedback="Tool execution not permitted.", ) - response, feedback = await self.approval_callback(tool_name, args, tool_call_id) + response, feedback = await self.approval_callback( + tool_name, args, tool_call_id, required_permissions + ) match response: case ApprovalResponse.YES: diff --git a/vibe/core/agents/models.py b/vibe/core/agents/models.py index b890e10..6a92005 100644 --- a/vibe/core/agents/models.py +++ b/vibe/core/agents/models.py @@ -57,7 +57,17 @@ class AgentProfile: def apply_to_config(self, base: VibeConfig) -> VibeConfig: from vibe.core.config import VibeConfig as VC - merged = _deep_merge(base.model_dump(), self.overrides) + merged = _deep_merge( + base.model_dump(), + {k: v for k, v in self.overrides.items() if k != "base_disabled"}, + ) + base_disabled = self.overrides.get("base_disabled") + if isinstance(base_disabled, list): + merged["disabled_tools"] = list({ + *base_disabled, + *merged.get("disabled_tools", []), + }) + return VC.model_validate(merged) @classmethod @@ -92,6 +102,7 @@ DEFAULT = AgentProfile( "Default", "Requires approval for tool executions", AgentSafety.NEUTRAL, + overrides={"base_disabled": ["exit_plan_mode"]}, ) PLAN = AgentProfile( BuiltinAgentName.PLAN, @@ -113,10 +124,11 @@ ACCEPT_EDITS = AgentProfile( "Auto-approves file edits only", AgentSafety.DESTRUCTIVE, overrides={ + "base_disabled": ["exit_plan_mode"], "tools": { "write_file": {"permission": "always"}, "search_replace": {"permission": "always"}, - } + }, }, ) AUTO_APPROVE = AgentProfile( @@ -124,7 +136,7 @@ AUTO_APPROVE = AgentProfile( "Auto Approve", "Auto-approves all tool executions", AgentSafety.YOLO, - overrides={"auto_approve": True}, + overrides={"auto_approve": True, "base_disabled": ["exit_plan_mode"]}, ) EXPLORE = AgentProfile( @@ -173,6 +185,7 @@ LEAN = AgentProfile( "thinking": "off", }, "tools": {"bash": {"default_timeout": 1200}}, + "base_disabled": ["exit_plan_mode"], }, ) diff --git a/vibe/core/audio_player/__init__.py b/vibe/core/audio_player/__init__.py new file mode 100644 index 0000000..ffa5875 --- /dev/null +++ b/vibe/core/audio_player/__init__.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from vibe.core.audio_player.audio_player import AudioPlayer +from vibe.core.audio_player.audio_player_port import ( + AlreadyPlayingError, + AudioBackendUnavailableError, + AudioFormat, + AudioPlayerPort, + NoAudioOutputDeviceError, + UnsupportedAudioFormatError, +) + +__all__ = [ + "AlreadyPlayingError", + "AudioBackendUnavailableError", + "AudioFormat", + "AudioPlayer", + "AudioPlayerPort", + "NoAudioOutputDeviceError", + "UnsupportedAudioFormatError", +] diff --git a/vibe/core/audio_player/audio_player.py b/vibe/core/audio_player/audio_player.py new file mode 100644 index 0000000..c03d39d --- /dev/null +++ b/vibe/core/audio_player/audio_player.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from collections.abc import Callable +import threading +from typing import TYPE_CHECKING + +from vibe.core.audio_player.audio_player_port import ( + AlreadyPlayingError, + AudioBackendUnavailableError, + AudioFormat, + NoAudioOutputDeviceError, + UnsupportedAudioFormatError, +) +from vibe.core.audio_player.utils import decode_wav +from vibe.core.logger import logger + +# sounddevice raises OSError on import when no audio driver is available. +try: + import sounddevice as sd + + if TYPE_CHECKING: + from sounddevice import CallbackFlags, RawOutputStream +except OSError: + sd = None # type: ignore[assignment] + +DEFAULT_BLOCKSIZE = 4096 +DTYPE = "int16" +DEFAULT_SAMPLE_WIDTH = 2 # 16-bit = 2 bytes + + +class AudioPlayer: + """Plays audio through the default output device using sounddevice.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._stream: RawOutputStream | None = None + self._playing: bool = False + self._audio_data: bytes = b"" + self._position: int = 0 + self._frame_size: int = 0 + self._on_finished: Callable[[], object] | None = None + + @property + def is_playing(self) -> bool: + return self._playing + + def play( + self, + audio_data: bytes, + audio_format: AudioFormat, + *, + on_finished: Callable[[], object] | None = None, + ) -> None: + with self._lock: + if self._playing: + raise AlreadyPlayingError("Already playing") + + if not sd: + error_message = "sounddevice is not available, audio playback disabled" + logger.error(error_message) + raise AudioBackendUnavailableError(error_message) + + self._guard_audio_output() + + match audio_format: + case AudioFormat.WAV: + sample_rate, channels, pcm_data = decode_wav(audio_data) + case _: + raise UnsupportedAudioFormatError( + f"Unsupported audio format: {audio_format}" + ) + self._audio_data = pcm_data + self._position = 0 + self._frame_size = channels * DEFAULT_SAMPLE_WIDTH + self._on_finished = on_finished + + self._stream = sd.RawOutputStream( + samplerate=sample_rate, + channels=channels, + dtype=DTYPE, + blocksize=DEFAULT_BLOCKSIZE, + callback=self._audio_callback, + finished_callback=self._on_stream_finished, + ) + self._stream.start() + self._playing = True + + def stop(self) -> None: + stream = self._stream + if not self._playing or stream is None: + return + stream.close(ignore_errors=True) + + def _audio_callback( + self, outdata: memoryview, frames: int, time_info: object, status: CallbackFlags + ) -> None: + if not sd: + raise RuntimeError("sounddevice is not available") + if status: + logger.warning(f"Audio playback callback status: {status}") + + bytes_needed = frames * self._frame_size + chunk = self._audio_data[self._position : self._position + bytes_needed] + self._position += len(chunk) + + if len(chunk) < bytes_needed: + outdata[: len(chunk)] = chunk + outdata[len(chunk) :] = b"\x00" * (bytes_needed - len(chunk)) + raise sd.CallbackStop() + else: + outdata[:] = chunk + + def _on_stream_finished(self) -> None: + on_finished = None + with self._lock: + self._stream = None + self._playing = False + on_finished = self._on_finished + + if on_finished is not None: + on_finished() + + @staticmethod + def _guard_audio_output() -> None: + if sd is None: + raise RuntimeError("sounddevice is not available") + try: + sd.query_devices(kind="output") + except Exception as exc: + raise NoAudioOutputDeviceError("No audio output device available") from exc diff --git a/vibe/core/audio_player/audio_player_port.py b/vibe/core/audio_player/audio_player_port.py new file mode 100644 index 0000000..6d26ae5 --- /dev/null +++ b/vibe/core/audio_player/audio_player_port.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from collections.abc import Callable +from enum import StrEnum, auto +from typing import Protocol + + +class AudioFormat(StrEnum): + WAV = auto() + + +class AlreadyPlayingError(Exception): + pass + + +class AudioBackendUnavailableError(Exception): + pass + + +class NoAudioOutputDeviceError(Exception): + pass + + +class UnsupportedAudioFormatError(Exception): + pass + + +class AudioPlayerPort(Protocol): + @property + def is_playing(self) -> bool: ... + + def play( + self, + audio_data: bytes, + audio_format: AudioFormat, + *, + on_finished: Callable[[], object] | None = ..., + ) -> None: ... + + def stop(self) -> None: ... diff --git a/vibe/core/audio_player/utils.py b/vibe/core/audio_player/utils.py new file mode 100644 index 0000000..e8bc7d8 --- /dev/null +++ b/vibe/core/audio_player/utils.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +import io +import wave + + +def decode_wav(audio_data: bytes) -> tuple[int, int, bytes]: + with wave.open(io.BytesIO(audio_data), "rb") as wf: + sample_rate = wf.getframerate() + channels = wf.getnchannels() + pcm_data = wf.readframes(wf.getnframes()) + return sample_rate, channels, pcm_data diff --git a/vibe/core/audio_recorder/audio_recorder.py b/vibe/core/audio_recorder/audio_recorder.py index b3a10d5..020a362 100644 --- a/vibe/core/audio_recorder/audio_recorder.py +++ b/vibe/core/audio_recorder/audio_recorder.py @@ -19,6 +19,7 @@ from vibe.core.audio_recorder.audio_recorder_port import ( ) from vibe.core.logger import logger +# sounddevice raises OSError on import when no audio driver is available. try: import sounddevice as sd diff --git a/vibe/core/autocompletion/file_indexer/ignore_rules.py b/vibe/core/autocompletion/file_indexer/ignore_rules.py index f59939c..b20cc45 100644 --- a/vibe/core/autocompletion/file_indexer/ignore_rules.py +++ b/vibe/core/autocompletion/file_indexer/ignore_rules.py @@ -4,6 +4,8 @@ from dataclasses import dataclass import fnmatch from pathlib import Path +from vibe.core.utils.io import read_safe + DEFAULT_IGNORE_PATTERNS: list[tuple[str, bool]] = [ (".git/", True), ("__pycache__/", True), @@ -111,7 +113,7 @@ class IgnoreRules: gitignore_path = root / ".gitignore" if gitignore_path.exists(): try: - text = gitignore_path.read_text(encoding="utf-8") + text = read_safe(gitignore_path) except Exception: return patterns diff --git a/vibe/core/config/__init__.py b/vibe/core/config/__init__.py index 1a50747..1722fe5 100644 --- a/vibe/core/config/__init__.py +++ b/vibe/core/config/__init__.py @@ -6,7 +6,8 @@ from vibe.core.config._settings import ( DEFAULT_PROVIDERS, DEFAULT_TRANSCRIBE_MODELS, DEFAULT_TRANSCRIBE_PROVIDERS, - Backend, + DEFAULT_TTS_MODELS, + DEFAULT_TTS_PROVIDERS, MCPHttp, MCPServer, MCPStdio, @@ -14,6 +15,7 @@ from vibe.core.config._settings import ( MissingAPIKeyError, MissingPromptFileError, ModelConfig, + OtelExporterConfig, ProjectContextConfig, ProviderConfig, SessionLoggingConfig, @@ -21,6 +23,9 @@ from vibe.core.config._settings import ( TranscribeClient, TranscribeModelConfig, TranscribeProviderConfig, + TTSClient, + TTSModelConfig, + TTSProviderConfig, VibeConfig, load_dotenv_values, ) @@ -31,7 +36,8 @@ __all__ = [ "DEFAULT_PROVIDERS", "DEFAULT_TRANSCRIBE_MODELS", "DEFAULT_TRANSCRIBE_PROVIDERS", - "Backend", + "DEFAULT_TTS_MODELS", + "DEFAULT_TTS_PROVIDERS", "MCPHttp", "MCPServer", "MCPStdio", @@ -39,9 +45,13 @@ __all__ = [ "MissingAPIKeyError", "MissingPromptFileError", "ModelConfig", + "OtelExporterConfig", "ProjectContextConfig", "ProviderConfig", "SessionLoggingConfig", + "TTSClient", + "TTSModelConfig", + "TTSProviderConfig", "TomlFileSettingsSource", "TranscribeClient", "TranscribeModelConfig", diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py index 1ecbd5b..d328288 100644 --- a/vibe/core/config/_settings.py +++ b/vibe/core/config/_settings.py @@ -8,9 +8,10 @@ import re import shlex import tomllib from typing import Annotated, Any, Literal +from urllib.parse import urljoin from dotenv import dotenv_values -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from pydantic.fields import FieldInfo from pydantic_core import to_jsonable_python from pydantic_settings import ( @@ -21,9 +22,12 @@ from pydantic_settings import ( import tomli_w from vibe.core.config.harness_files import get_harness_files_manager +from vibe.core.logger import logger from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR from vibe.core.prompts import SystemPrompt -from vibe.core.tools.base import BaseToolConfig +from vibe.core.types import Backend +from vibe.core.utils import get_server_url_from_api_base +from vibe.core.utils.io import read_safe def load_dotenv_values( @@ -114,11 +118,6 @@ class SessionLoggingConfig(BaseSettings): return str(Path(v).expanduser().resolve()) -class Backend(StrEnum): - MISTRAL = auto() - GENERIC = auto() - - class ProviderConfig(BaseModel): name: str api_base: str @@ -271,13 +270,42 @@ class TranscribeModelConfig(BaseModel): _default_alias_to_name = model_validator(mode="before")(_default_alias_to_name) -DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY" +class TTSClient(StrEnum): + MISTRAL = auto() +class TTSProviderConfig(BaseModel): + name: str + api_base: str = "https://api.mistral.ai" + api_key_env_var: str = "" + client: TTSClient = TTSClient.MISTRAL + + +class TTSModelConfig(BaseModel): + name: str + provider: str + alias: str + voice: str = "gb_jane_neutral" + response_format: str = "wav" + + _default_alias_to_name = model_validator(mode="before")(_default_alias_to_name) + + +class OtelExporterConfig(BaseModel): + model_config = ConfigDict(frozen=True) + + endpoint: str + headers: dict[str, str] | None = None + + +DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY" +MISTRAL_OTEL_TRACES_PATH = "/telemetry/v1/traces" +_DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai" + DEFAULT_PROVIDERS = [ ProviderConfig( name="mistral", - api_base="https://api.mistral.ai/v1", + api_base=f"{_DEFAULT_MISTRAL_SERVER_URL}/v1", api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY, backend=Backend.MISTRAL, ), @@ -328,6 +356,20 @@ DEFAULT_TRANSCRIBE_MODELS = [ ) ] +DEFAULT_TTS_PROVIDERS = [ + TTSProviderConfig( + name="mistral", + api_base="https://api.mistral.ai", + api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY, + ) +] + +DEFAULT_TTS_MODELS = [ + TTSModelConfig( + name="voxtral-mini-tts-latest", provider="mistral", alias="voxtral-tts" + ) +] + class VibeConfig(BaseSettings): active_model: str = "devstral-2" @@ -338,7 +380,9 @@ class VibeConfig(BaseSettings): displayed_workdir: str = "" context_warnings: bool = False voice_mode_enabled: bool = False + narrator_enabled: bool = False active_transcribe_model: str = "voxtral-realtime" + active_tts_model: str = "voxtral-tts" auto_approve: bool = False enable_telemetry: bool = True system_prompt_id: str = "cli" @@ -360,6 +404,10 @@ class VibeConfig(BaseSettings): # TODO(vibe-nuage): change default value to MISTRAL_API_KEY once prod has shared vibe-nuage workers nuage_api_key_env_var: str = Field(default="STAGING_MISTRAL_API_KEY", exclude=True) + # TODO(otel): remove exclude=True once the feature is publicly available + enable_otel: bool = Field(default=False, exclude=True) + otel_endpoint: str = Field(default="", exclude=True) + providers: list[ProviderConfig] = Field( default_factory=lambda: list(DEFAULT_PROVIDERS) ) @@ -373,9 +421,16 @@ class VibeConfig(BaseSettings): default_factory=lambda: list(DEFAULT_TRANSCRIBE_MODELS) ) + tts_providers: list[TTSProviderConfig] = Field( + default_factory=lambda: list(DEFAULT_TTS_PROVIDERS) + ) + tts_models: list[TTSModelConfig] = Field( + default_factory=lambda: list(DEFAULT_TTS_MODELS) + ) + project_context: ProjectContextConfig = Field(default_factory=ProjectContextConfig) session_logging: SessionLoggingConfig = Field(default_factory=SessionLoggingConfig) - tools: dict[str, BaseToolConfig] = Field(default_factory=dict) + tools: dict[str, dict[str, Any]] = Field(default_factory=dict) tool_paths: list[Path] = Field( default_factory=list, description=( @@ -468,6 +523,39 @@ class VibeConfig(BaseSettings): def nuage_api_key(self) -> str: return os.getenv(self.nuage_api_key_env_var, "") + @property + def otel_exporter_config(self) -> OtelExporterConfig | None: + # When otel_endpoint is set explicitly, authentication is the user's responsibility + # (via OTEL_EXPORTER_OTLP_* env vars), so headers are left empty. + # Otherwise endpoint and API key are derived from the first MISTRAL provider. + if self.otel_endpoint: + return OtelExporterConfig(endpoint=self.otel_endpoint) + + provider = next( + (p for p in self.providers if p.backend == Backend.MISTRAL), None + ) + + if provider is not None: + server_url = get_server_url_from_api_base(provider.api_base) + api_key_env = provider.api_key_env_var or DEFAULT_MISTRAL_API_ENV_KEY + else: + server_url = None + api_key_env = DEFAULT_MISTRAL_API_ENV_KEY + + endpoint = urljoin( + server_url or _DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_TRACES_PATH + ) + + if not (api_key := os.getenv(api_key_env)): + logger.warning( + "OTEL tracing enabled but %s is not set; skipping.", api_key_env + ) + return None + + return OtelExporterConfig( + endpoint=endpoint, headers={"Authorization": f"Bearer {api_key}"} + ) + @property def system_prompt(self) -> str: try: @@ -482,7 +570,7 @@ class VibeConfig(BaseSettings): ".md" ) if custom_sp_path.is_file(): - return custom_sp_path.read_text() + return read_safe(custom_sp_path) raise MissingPromptFileError( self.system_prompt_id, *(str(d) for d in prompt_dirs) @@ -527,6 +615,22 @@ class VibeConfig(BaseSettings): f"Transcribe provider '{model.provider}' for transcribe model '{model.name}' not found in configuration." ) + def get_active_tts_model(self) -> TTSModelConfig: + for model in self.tts_models: + if model.alias == self.active_tts_model: + return model + raise ValueError( + f"Active TTS model '{self.active_tts_model}' not found in configuration." + ) + + def get_tts_provider_for_model(self, model: TTSModelConfig) -> TTSProviderConfig: + for provider in self.tts_providers: + if provider.name == model.provider: + return provider + raise ValueError( + f"TTS provider '{model.provider}' for TTS model '{model.name}' not found in configuration." + ) + @classmethod def settings_customise_sources( cls, @@ -608,18 +712,16 @@ class VibeConfig(BaseSettings): @field_validator("tools", mode="before") @classmethod - def _normalize_tool_configs(cls, v: Any) -> dict[str, BaseToolConfig]: + def _normalize_tool_configs(cls, v: Any) -> dict[str, dict[str, Any]]: if not isinstance(v, dict): return {} - normalized: dict[str, BaseToolConfig] = {} + normalized: dict[str, dict[str, Any]] = {} for tool_name, tool_config in v.items(): - if isinstance(tool_config, BaseToolConfig): + if isinstance(tool_config, dict): normalized[tool_name] = tool_config - elif isinstance(tool_config, dict): - normalized[tool_name] = BaseToolConfig.model_validate(tool_config) else: - normalized[tool_name] = BaseToolConfig() + normalized[tool_name] = {} return normalized @@ -645,6 +747,17 @@ class VibeConfig(BaseSettings): seen_aliases.add(model.alias) return self + @model_validator(mode="after") + def _validate_tts_model_uniqueness(self) -> VibeConfig: + seen_aliases: set[str] = set() + for model in self.tts_models: + if model.alias in seen_aliases: + raise ValueError( + f"Duplicate TTS model alias found: '{model.alias}'. Aliases must be unique." + ) + seen_aliases.add(model.alias) + return self + @model_validator(mode="after") def _check_system_prompt(self) -> VibeConfig: _ = self.system_prompt @@ -674,6 +787,8 @@ class VibeConfig(BaseSettings): "models", "transcribe_providers", "transcribe_models", + "tts_providers", + "tts_models", "installed_agents", }: target[key] = value diff --git a/vibe/core/config/harness_files/_harness_manager.py b/vibe/core/config/harness_files/_harness_manager.py index f3553c6..7bf9ac1 100644 --- a/vibe/core/config/harness_files/_harness_manager.py +++ b/vibe/core/config/harness_files/_harness_manager.py @@ -12,6 +12,7 @@ from vibe.core.config.harness_files._paths import ( ) from vibe.core.paths import AGENTS_MD_FILENAME, VIBE_HOME, walk_local_config_dirs_all from vibe.core.trusted_folders import trusted_folders_manager +from vibe.core.utils.io import read_safe FileSource = Literal["user", "project"] @@ -115,8 +116,7 @@ class HarnessFilesManager: return "" path = VIBE_HOME.path / AGENTS_MD_FILENAME try: - content = path.read_text("utf-8", errors="ignore") - stripped = content.strip() + stripped = read_safe(path).strip() return stripped if stripped else "" except (FileNotFoundError, OSError): return "" @@ -140,8 +140,7 @@ class HarnessFilesManager: break path = current / AGENTS_MD_FILENAME try: - content = path.read_text("utf-8", errors="ignore") - stripped = content.strip() + stripped = read_safe(path).strip() if stripped: docs.append((current, stripped)) except (FileNotFoundError, OSError): diff --git a/vibe/core/llm/backend/factory.py b/vibe/core/llm/backend/factory.py index d3bb919..458d6a1 100644 --- a/vibe/core/llm/backend/factory.py +++ b/vibe/core/llm/backend/factory.py @@ -1,7 +1,7 @@ from __future__ import annotations -from vibe.core.config import Backend from vibe.core.llm.backend.generic import GenericBackend from vibe.core.llm.backend.mistral import MistralBackend +from vibe.core.types import Backend BACKEND_FACTORY = {Backend.MISTRAL: MistralBackend, Backend.GENERIC: GenericBackend} diff --git a/vibe/core/llm/backend/mistral.py b/vibe/core/llm/backend/mistral.py index a149e63..6cfa4c3 100644 --- a/vibe/core/llm/backend/mistral.py +++ b/vibe/core/llm/backend/mistral.py @@ -14,6 +14,7 @@ from mistralai.client.models import ( AssistantMessageContent, ChatCompletionRequestMessage, ChatCompletionStreamRequestToolChoice, + ContentChunk, FileChunk, Function, FunctionCall as MistralFunctionCall, @@ -64,15 +65,17 @@ class MistralMapper: case Role.assistant: content: AssistantMessageContent if msg.reasoning_content: - content = [ + chunks: list[ContentChunk] = [ ThinkChunk( type="thinking", thinking=[ TextChunk(type="text", text=msg.reasoning_content) ], - ), - TextChunk(type="text", text=msg.content or ""), + ) ] + if msg.content: + chunks.append(TextChunk(type="text", text=msg.content)) + content = chunks else: content = msg.content or "" @@ -326,7 +329,7 @@ class MistralBackend: ) -> AsyncGenerator[LLMChunk, None]: try: merged_messages = merge_consecutive_user_messages(messages) - async for chunk in await self._get_client().chat.stream_async( + stream = await self._get_client().chat.stream_async( model=model.name, messages=[self._mapper.prepare_message(msg) for msg in merged_messages], temperature=temperature, @@ -339,7 +342,9 @@ class MistralBackend: else None, http_headers=extra_headers, metadata=metadata, - ): + ) + correlation_id = stream.response.headers.get("mistral-correlation-id") + async for chunk in stream: parsed = ( self._mapper.parse_content(chunk.data.choices[0].delta.content) if chunk.data.choices[0].delta.content @@ -364,6 +369,7 @@ class MistralBackend: if chunk.data.usage else 0, ), + correlation_id=correlation_id, ) except SDKError as e: diff --git a/vibe/core/llm/backend/reasoning_adapter.py b/vibe/core/llm/backend/reasoning_adapter.py index 8417b5f..b794f2a 100644 --- a/vibe/core/llm/backend/reasoning_adapter.py +++ b/vibe/core/llm/backend/reasoning_adapter.py @@ -48,9 +48,10 @@ class ReasoningAdapter(APIAdapter): { "type": "thinking", "thinking": [{"type": "text", "text": msg.reasoning_content}], - }, - {"type": "text", "text": msg.content or ""}, + } ] + if msg.content: + content.append({"type": "text", "text": msg.content}) result["content"] = content else: result["content"] = msg.content or "" diff --git a/vibe/core/paths/__init__.py b/vibe/core/paths/__init__.py index 5db117f..dc5780a 100644 --- a/vibe/core/paths/__init__.py +++ b/vibe/core/paths/__init__.py @@ -1,6 +1,10 @@ from __future__ import annotations -from vibe.core.paths._local_config_walk import walk_local_config_dirs_all +from vibe.core.paths._local_config_walk import ( + WALK_MAX_DEPTH, + has_config_dirs_nearby, + walk_local_config_dirs_all, +) from vibe.core.paths._vibe_home import ( DEFAULT_TOOL_DIR, GLOBAL_ENV_FILE, @@ -26,6 +30,8 @@ __all__ = [ "SESSION_LOG_DIR", "TRUSTED_FOLDERS_FILE", "VIBE_HOME", + "WALK_MAX_DEPTH", "GlobalPath", + "has_config_dirs_nearby", "walk_local_config_dirs_all", ] diff --git a/vibe/core/paths/_local_config_walk.py b/vibe/core/paths/_local_config_walk.py index e973285..f4abf88 100644 --- a/vibe/core/paths/_local_config_walk.py +++ b/vibe/core/paths/_local_config_walk.py @@ -1,11 +1,15 @@ from __future__ import annotations +from collections import deque from functools import cache +import logging import os from pathlib import Path from vibe.core.autocompletion.file_indexer.ignore_rules import WALK_SKIP_DIR_NAMES +logger = logging.getLogger("vibe") + _VIBE_DIR = ".vibe" _TOOLS_SUBDIR = Path(_VIBE_DIR) / "tools" _VIBE_SKILLS_SUBDIR = Path(_VIBE_DIR) / "skills" @@ -13,27 +17,117 @@ _AGENTS_SUBDIR = Path(_VIBE_DIR) / "agents" _AGENTS_DIR = ".agents" _AGENTS_SKILLS_SUBDIR = Path(_AGENTS_DIR) / "skills" +WALK_MAX_DEPTH = 4 +_MAX_DIRS = 2000 + + +def _collect_config_dirs_at( + path: Path, + entries: set[str], + tools: list[Path], + skills: list[Path], + agents: list[Path], +) -> None: + """Check a single directory for .vibe/ and .agents/ config subdirs.""" + if _VIBE_DIR in entries: + if (candidate := path / _TOOLS_SUBDIR).is_dir(): + tools.append(candidate) + if (candidate := path / _VIBE_SKILLS_SUBDIR).is_dir(): + skills.append(candidate) + if (candidate := path / _AGENTS_SUBDIR).is_dir(): + agents.append(candidate) + if _AGENTS_DIR in entries: + if (candidate := path / _AGENTS_SKILLS_SUBDIR).is_dir(): + skills.append(candidate) + + +def _iter_child_dirs(path: Path, entries: set[str]) -> list[Path]: + """Return sorted child directories to descend into, skipping ignored and dot-dirs.""" + children: list[Path] = [] + for name in sorted(entries): + if name in WALK_SKIP_DIR_NAMES or name.startswith("."): + continue + child = path / name + try: + if child.is_dir(): + children.append(child) + except OSError: + continue + return children + @cache def walk_local_config_dirs_all( root: Path, ) -> tuple[tuple[Path, ...], tuple[Path, ...], tuple[Path, ...]]: + """Discover .vibe/ and .agents/ config directories under *root*. + + Uses breadth-first search bounded by ``WALK_MAX_DEPTH`` and ``_MAX_DIRS`` + to avoid unbounded traversal in large repositories. + """ tools_dirs: list[Path] = [] skills_dirs: list[Path] = [] agents_dirs: list[Path] = [] + resolved_root = root.resolve() - for dirpath, dirnames, _ in os.walk(resolved_root, topdown=True): - dir_set = frozenset(dirnames) - path = Path(dirpath) - if _VIBE_DIR in dir_set: - if (candidate := path / _TOOLS_SUBDIR).is_dir(): - tools_dirs.append(candidate) - if (candidate := path / _VIBE_SKILLS_SUBDIR).is_dir(): - skills_dirs.append(candidate) - if (candidate := path / _AGENTS_SUBDIR).is_dir(): - agents_dirs.append(candidate) - if _AGENTS_DIR in dir_set: - if (candidate := path / _AGENTS_SKILLS_SUBDIR).is_dir(): - skills_dirs.append(candidate) - dirnames[:] = sorted(d for d in dirnames if d not in WALK_SKIP_DIR_NAMES) + queue: deque[tuple[Path, int]] = deque([(resolved_root, 0)]) + visited = 0 + + while queue and visited < _MAX_DIRS: + current, depth = queue.popleft() + visited += 1 + + try: + entries = set(os.listdir(current)) + except OSError: + continue + + _collect_config_dirs_at(current, entries, tools_dirs, skills_dirs, agents_dirs) + + if depth < WALK_MAX_DEPTH: + queue.extend( + (child, depth + 1) for child in _iter_child_dirs(current, entries) + ) + + if visited >= _MAX_DIRS: + logger.warning( + "Config directory scan reached directory limit (%d dirs) at %s", + _MAX_DIRS, + resolved_root, + ) + return (tuple(tools_dirs), tuple(skills_dirs), tuple(agents_dirs)) + + +def has_config_dirs_nearby( + root: Path, *, max_depth: int = WALK_MAX_DEPTH, max_dirs: int = 200 +) -> bool: + """Quick check for .vibe/ or .agents/ config dirs in the near subtree. + + Returns ``True`` as soon as any config directory is found, without + enumerating all of them. + """ + resolved = root.resolve() + queue: deque[tuple[Path, int]] = deque([(resolved, 0)]) + visited = 0 + found: list[Path] = [] + + while queue and visited < max_dirs: + current, depth = queue.popleft() + visited += 1 + + try: + entries = set(os.listdir(current)) + except OSError: + continue + + _collect_config_dirs_at(current, entries, found, found, found) + if found: + return True + + if depth < max_depth: + queue.extend( + (child, depth + 1) for child in _iter_child_dirs(current, entries) + ) + + return False diff --git a/vibe/core/plan_session.py b/vibe/core/plan_session.py index 107bfda..c6a5e27 100644 --- a/vibe/core/plan_session.py +++ b/vibe/core/plan_session.py @@ -4,7 +4,7 @@ from pathlib import Path import time from vibe.core.paths import PLANS_DIR -from vibe.core.slug import create_slug +from vibe.core.utils.slug import create_slug class PlanSession: diff --git a/vibe/core/prompts/__init__.py b/vibe/core/prompts/__init__.py index db4daba..97e8392 100644 --- a/vibe/core/prompts/__init__.py +++ b/vibe/core/prompts/__init__.py @@ -4,6 +4,7 @@ from enum import StrEnum, auto from pathlib import Path from vibe import VIBE_ROOT +from vibe.core.utils.io import read_safe _PROMPTS_DIR = VIBE_ROOT / "core" / "prompts" @@ -14,7 +15,7 @@ class Prompt(StrEnum): return (_PROMPTS_DIR / self.value).with_suffix(".md") def read(self) -> str: - return self.path.read_text(encoding="utf-8", errors="ignore").strip() + return read_safe(self.path).strip() class SystemPrompt(Prompt): @@ -29,6 +30,7 @@ class UtilityPrompt(Prompt): COMPACT = auto() DANGEROUS_DIRECTORY = auto() PROJECT_CONTEXT = auto() + TURN_SUMMARY = auto() __all__ = ["SystemPrompt", "UtilityPrompt"] diff --git a/vibe/core/prompts/cli.md b/vibe/core/prompts/cli.md index 95630fe..15d3eb2 100644 --- a/vibe/core/prompts/cli.md +++ b/vibe/core/prompts/cli.md @@ -1,14 +1,6 @@ -You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools. You have no internet access. +You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools. CRITICAL: Users complain you are too verbose. Your responses must be minimal. Most tasks need <100 words. Code speaks for itself. -Skills are markdown files in your skill directories, NOT tools or agents. To use a skill: - -1. Find the matching file in your skill directories. -2. Read it with `read_file`. -3. Follow its instructions step by step. You are the executor. - -Do not try to invoke a skill as a tool or command. If the user references a skill by name (e.g., "iterate on this PR"), look for a file with that name and follow its contents. - Phase 1 — Orient Before ANY action: Restate the goal in one line. diff --git a/vibe/core/prompts/lean.md b/vibe/core/prompts/lean.md index 9f6b5c8..1bf68db 100644 --- a/vibe/core/prompts/lean.md +++ b/vibe/core/prompts/lean.md @@ -2,14 +2,6 @@ You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with Use markdown when appropriate. Communicate clearly to the user. -Skills are markdown files in your skill directories, NOT tools or agents. To use a skill: - -1. Find the matching file in your skill directories. -2. Read it with `read_file`. -3. Follow its instructions step by step. You are the executor. - -Do not try to invoke a skill as a tool or command. If the user references a skill by name (e.g., "iterate on this PR"), look for a file with that name and follow its contents. - Phase 1 — Orient Before ANY action: Restate the goal in one line. diff --git a/vibe/core/prompts/turn_summary.md b/vibe/core/prompts/turn_summary.md new file mode 100644 index 0000000..4670d44 --- /dev/null +++ b/vibe/core/prompts/turn_summary.md @@ -0,0 +1,10 @@ +You are a concise turn summarizer for an AI coding agent. Given a structured extraction of what happened during one agent turn, produce a brief summary (2-4 sentences). + +Cover: +- What the user asked for +- What actions were taken (tools used, files modified) +- The outcome (success, partial, errors) +- Any open questions or issues for the user (only if genuinely relevant, do not force this) +- Do not expand if it does not bring value (e.g. no question to ask) + +Be factual and terse. Do not editorialize. Do not repeat the full user message. Respond with ONLY the summary text. diff --git a/vibe/core/session/session_loader.py b/vibe/core/session/session_loader.py index fd45aa8..c065396 100644 --- a/vibe/core/session/session_loader.py +++ b/vibe/core/session/session_loader.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, TypedDict from vibe.core.types import LLMMessage, SessionMetadata +from vibe.core.utils.io import read_safe if TYPE_CHECKING: from vibe.core.config import SessionLoggingConfig @@ -181,7 +182,7 @@ class SessionLoader: raise ValueError(f"Session metadata not found at {session_dir}") try: - metadata_content = metadata_path.read_text("utf-8", errors="ignore") + metadata_content = read_safe(metadata_path) return SessionMetadata.model_validate_json(metadata_content) except ValueError: raise @@ -196,8 +197,9 @@ class SessionLoader: messages_filepath = filepath / MESSAGES_FILENAME try: - with messages_filepath.open("r", encoding="utf-8", errors="ignore") as f: - content = f.readlines() + content = read_safe(messages_filepath).split("\n") + if content and content[-1] == "": + content.pop() except Exception as e: raise ValueError( f"Error reading session messages at {filepath}: {e}" diff --git a/vibe/core/session/session_logger.py b/vibe/core/session/session_logger.py index c1f865e..362d9a7 100644 --- a/vibe/core/session/session_logger.py +++ b/vibe/core/session/session_logger.py @@ -19,6 +19,7 @@ from vibe.core.session.session_loader import ( ) from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata from vibe.core.utils import is_windows, utc_now +from vibe.core.utils.io import read_safe_async if TYPE_CHECKING: from vibe.core.agents.models import AgentProfile @@ -230,11 +231,9 @@ class SessionLogger: # Read old metadata and get total_messages try: if self.metadata_filepath.exists(): - async with await AsyncPath(self.metadata_filepath).open( - encoding="utf-8", errors="ignore" - ) as f: - old_metadata = json.loads(await f.read()) - old_total_messages = old_metadata["total_messages"] + raw = await read_safe_async(self.metadata_filepath) + old_metadata = json.loads(raw) + old_total_messages = old_metadata["total_messages"] else: old_total_messages = 0 except Exception as e: diff --git a/vibe/core/session/session_migration.py b/vibe/core/session/session_migration.py index 3b4a6bf..cef852c 100644 --- a/vibe/core/session/session_migration.py +++ b/vibe/core/session/session_migration.py @@ -6,6 +6,7 @@ from pathlib import Path from vibe.core.config import SessionLoggingConfig from vibe.core.session.session_logger import SessionLogger +from vibe.core.utils.io import read_safe def migrate_sessions_entrypoint(session_config: SessionLoggingConfig) -> int: @@ -22,8 +23,7 @@ async def migrate_sessions(session_config: SessionLoggingConfig) -> int: session_files = list(Path(save_dir).glob(f"{session_config.session_prefix}_*.json")) for session_file in session_files: try: - with open(session_file) as f: - session_data = f.read() + session_data = read_safe(session_file) session_json = json.loads(session_data) metadata = session_json["metadata"] messages = session_json["messages"] diff --git a/vibe/core/skills/manager.py b/vibe/core/skills/manager.py index de53f62..45a780f 100644 --- a/vibe/core/skills/manager.py +++ b/vibe/core/skills/manager.py @@ -9,6 +9,7 @@ from vibe.core.logger import logger from vibe.core.skills.models import SkillInfo, SkillMetadata from vibe.core.skills.parser import SkillParseError, parse_frontmatter from vibe.core.utils import name_matches +from vibe.core.utils.io import read_safe if TYPE_CHECKING: from vibe.core.config import VibeConfig @@ -106,7 +107,7 @@ class SkillManager: def _parse_skill_file(self, skill_path: Path) -> SkillInfo: try: - content = skill_path.read_text(encoding="utf-8") + content = read_safe(skill_path) except OSError as e: raise SkillParseError(f"Cannot read file: {e}") from e diff --git a/vibe/core/system_prompt.py b/vibe/core/system_prompt.py index 4713072..7c9b998 100644 --- a/vibe/core/system_prompt.py +++ b/vibe/core/system_prompt.py @@ -212,7 +212,7 @@ def _get_available_skills_section(skill_manager: SkillManager) -> str: "# Available Skills", "", "You have access to the following skills. When a task matches a skill's description,", - "read the full SKILL.md file to load detailed instructions.", + "use the `skill` tool if available to load the full skill instructions, if it is not available, read the files manually.", "", "", ] diff --git a/vibe/core/telemetry/send.py b/vibe/core/telemetry/send.py index 47faa34..4821edd 100644 --- a/vibe/core/telemetry/send.py +++ b/vibe/core/telemetry/send.py @@ -8,8 +8,9 @@ from typing import TYPE_CHECKING, Any, Literal import httpx from vibe import __version__ -from vibe.core.config import Backend, VibeConfig +from vibe.core.config import VibeConfig from vibe.core.llm.format import ResolvedToolCall +from vibe.core.types import Backend from vibe.core.utils import get_user_agent if TYPE_CHECKING: @@ -28,6 +29,7 @@ class TelemetryClient: self._session_id_getter = session_id_getter self._client: httpx.AsyncClient | None = None self._pending_tasks: set[asyncio.Task[Any]] = set() + self.last_correlation_id: str | None = None def _get_telemetry_user_agent(self) -> str: try: @@ -62,6 +64,9 @@ class TelemetryClient: except ValueError: return False + def is_active(self) -> bool: + return self._is_enabled() and self._get_mistral_api_key() is not None + @property def client(self) -> httpx.AsyncClient: if self._client is None: @@ -71,7 +76,13 @@ class TelemetryClient: ) return self._client - def send_telemetry_event(self, event_name: str, properties: dict[str, Any]) -> None: + def send_telemetry_event( + self, + event_name: str, + properties: dict[str, Any], + *, + correlation_id: str | None = None, + ) -> None: mistral_api_key = self._get_mistral_api_key() if mistral_api_key is None or not self._is_enabled(): return @@ -82,11 +93,15 @@ class TelemetryClient: ): properties = {**properties, "session_id": session_id} + payload: dict[str, Any] = {"event": event_name, "properties": properties} + if correlation_id: + payload["correlation_id"] = correlation_id + async def _send() -> None: try: await self.client.post( DATALAKE_EVENTS_URL, - json={"event": event_name, "properties": properties}, + json=payload, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {mistral_api_key}", @@ -178,6 +193,8 @@ class TelemetryClient: nb_mcp_servers: int, nb_models: int, entrypoint: Literal["cli", "acp", "programmatic", "unknown"], + client_name: str | None, + client_version: str | None, terminal_emulator: str | None = None, ) -> None: payload = { @@ -187,6 +204,8 @@ class TelemetryClient: "nb_models": nb_models, "entrypoint": entrypoint, "version": __version__, + "client_name": client_name, + "client_version": client_version, "terminal_emulator": terminal_emulator, } self.send_telemetry_event("vibe.new_session", payload) @@ -195,3 +214,10 @@ class TelemetryClient: self.send_telemetry_event( "vibe.onboarding_api_key_added", {"version": __version__} ) + + def send_user_rating_feedback(self, rating: int, model: str) -> None: + self.send_telemetry_event( + "vibe.user_rating_feedback", + {"rating": rating, "version": __version__, "model": model}, + correlation_id=self.last_correlation_id, + ) diff --git a/vibe/core/tools/arity.py b/vibe/core/tools/arity.py new file mode 100644 index 0000000..b65dfc9 --- /dev/null +++ b/vibe/core/tools/arity.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +ARITY: dict[str, int] = { + "cat": 1, + "cd": 1, + "chmod": 1, + "chown": 1, + "cp": 1, + "echo": 1, + "env": 1, + "export": 1, + "grep": 1, + "kill": 1, + "killall": 1, + "ln": 1, + "ls": 1, + "mkdir": 1, + "mv": 1, + "ps": 1, + "pwd": 1, + "rm": 1, + "rmdir": 1, + "sleep": 1, + "source": 1, + "tail": 1, + "touch": 1, + "unset": 1, + "which": 1, + "aws": 3, + "az": 3, + "bazel": 2, + "brew": 2, + "bun": 2, + "bun run": 3, + "bun x": 3, + "cargo": 2, + "cargo add": 3, + "cargo run": 3, + "cdk": 2, + "cf": 2, + "cmake": 2, + "composer": 2, + "consul": 2, + "consul kv": 3, + "crictl": 2, + "deno": 2, + "deno task": 3, + "doctl": 3, + "docker": 2, + "docker builder": 3, + "docker compose": 3, + "docker container": 3, + "docker image": 3, + "docker network": 3, + "docker volume": 3, + "eksctl": 2, + "eksctl create": 3, + "firebase": 2, + "flyctl": 2, + "gcloud": 3, + "gh": 3, + "git": 2, + "git config": 3, + "git remote": 3, + "git stash": 3, + "go": 2, + "gradle": 2, + "helm": 2, + "heroku": 2, + "hugo": 2, + "ip": 2, + "ip addr": 3, + "ip link": 3, + "ip netns": 3, + "ip route": 3, + "kind": 2, + "kind create": 3, + "kubectl": 2, + "kubectl kustomize": 3, + "kubectl rollout": 3, + "kustomize": 2, + "make": 2, + "mc": 2, + "mc admin": 3, + "minikube": 2, + "mongosh": 2, + "mysql": 2, + "mvn": 2, + "ng": 2, + "npm": 2, + "npm exec": 3, + "npm init": 3, + "npm run": 3, + "npm view": 3, + "nvm": 2, + "nx": 2, + "openssl": 2, + "openssl req": 3, + "openssl x509": 3, + "pip": 2, + "pipenv": 2, + "pnpm": 2, + "pnpm dlx": 3, + "pnpm exec": 3, + "pnpm run": 3, + "poetry": 2, + "podman": 2, + "podman container": 3, + "podman image": 3, + "psql": 2, + "pulumi": 2, + "pulumi stack": 3, + "pyenv": 2, + "python": 2, + "rake": 2, + "rbenv": 2, + "redis-cli": 2, + "rustup": 2, + "serverless": 2, + "sfdx": 3, + "skaffold": 2, + "sls": 2, + "sst": 2, + "swift": 2, + "systemctl": 2, + "terraform": 2, + "terraform workspace": 3, + "tmux": 2, + "turbo": 2, + "ufw": 2, + "uv": 2, + "uv run": 3, + "vault": 2, + "vault auth": 3, + "vault kv": 3, + "vercel": 2, + "volta": 2, + "wp": 2, + "yarn": 2, + "yarn dlx": 3, + "yarn run": 3, +} + + +def build_session_pattern(tokens: list[str]) -> str: + """Build a session-level permission pattern from command tokens. + + Uses arity rules to find the meaningful command prefix, then appends " *" + to allow matching any arguments. Falls back to first token. + """ + if not tokens: + return "" + for length in range(len(tokens), 0, -1): + prefix = " ".join(tokens[:length]) + arity = ARITY.get(prefix) + if arity is not None: + return " ".join(tokens[:arity]) + " *" + return tokens[0] + " *" diff --git a/vibe/core/tools/base.py b/vibe/core/tools/base.py index 72ef077..43d7d52 100644 --- a/vibe/core/tools/base.py +++ b/vibe/core/tools/base.py @@ -24,10 +24,13 @@ from typing import ( from pydantic import BaseModel, ConfigDict, Field, ValidationError from vibe.core.types import ToolStreamEvent +from vibe.core.utils.io import read_safe if TYPE_CHECKING: from vibe.core.agents.manager import AgentManager + from vibe.core.skills.manager import SkillManager from vibe.core.tools.mcp_sampling import MCPSamplingHandler + from vibe.core.tools.permissions import PermissionContext from vibe.core.types import ( ApprovalCallback, EntrypointMetadata, @@ -51,6 +54,7 @@ class InvokeContext: entrypoint_metadata: EntrypointMetadata | None = field(default=None) plan_file_path: Path | None = field(default=None) switch_agent_callback: SwitchAgentCallback | None = field(default=None) + skill_manager: SkillManager | None = field(default=None) class ToolError(Exception): @@ -97,6 +101,7 @@ class BaseToolConfig(BaseModel): permission: The permission level required to use the tool. allowlist: Patterns that automatically allow tool execution. denylist: Patterns that automatically deny tool execution. + sensitive_patterns: Patterns that trigger ASK even when permission is ALWAYS. """ model_config = ConfigDict(extra="allow") @@ -104,6 +109,7 @@ class BaseToolConfig(BaseModel): permission: ToolPermission = ToolPermission.ASK allowlist: list[str] = Field(default_factory=list) denylist: list[str] = Field(default_factory=list) + sensitive_patterns: list[str] = Field(default_factory=list) class BaseToolState(BaseModel): @@ -153,7 +159,7 @@ class BaseTool[ prompt_dir = class_path.parent / "prompts" prompt_path = cls.prompt_path or prompt_dir / f"{class_path.stem}.md" - return prompt_path.read_text("utf-8") + return read_safe(prompt_path) except (FileNotFoundError, TypeError, OSError): pass @@ -338,12 +344,12 @@ class BaseTool[ config_class = cls._get_tool_config_class() return config_class(permission=permission) - def resolve_permission(self, args: ToolArgs) -> ToolPermission | None: + def resolve_permission(self, args: ToolArgs) -> PermissionContext | None: """Per-invocation permission override, checked before config-level permission. Returns: - ALWAYS if auto-approved, NEVER if blocked, ASK to force approval, - or None to fall through to config permission. + PermissionContext with granular required_permissions and a permission + level (ALWAYS/NEVER/ASK), or None to fall through to config permission. Override in subclasses for domain-specific rules (e.g. workdir checks). """ diff --git a/vibe/core/tools/builtins/bash.py b/vibe/core/tools/builtins/bash.py index d86ada8..559b43b 100644 --- a/vibe/core/tools/builtins/bash.py +++ b/vibe/core/tools/builtins/bash.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import AsyncGenerator from functools import lru_cache import os +from pathlib import Path import signal import sys from typing import ClassVar, Literal, final @@ -12,6 +13,7 @@ from pydantic import BaseModel, Field from tree_sitter import Language, Node, Parser import tree_sitter_bash as tsbash +from vibe.core.tools.arity import build_session_pattern from vibe.core.tools.base import ( BaseTool, BaseToolConfig, @@ -20,7 +22,13 @@ from vibe.core.tools.base import ( ToolError, ToolPermission, ) +from vibe.core.tools.permissions import ( + PermissionContext, + PermissionScope, + RequiredPermission, +) from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData +from vibe.core.tools.utils import is_path_within_workdir from vibe.core.types import ToolResultEvent, ToolStreamEvent from vibe.core.utils import is_windows @@ -116,7 +124,7 @@ async def _kill_process_tree(proc: asyncio.subprocess.Process) -> None: def _get_default_allowlist() -> list[str]: - common = ["echo", "git diff", "git log", "git status", "tree", "whoami"] + common = ["cd", "echo", "git diff", "git log", "git status", "tree", "whoami"] if is_windows(): return common + ["dir", "findstr", "more", "type", "ver", "where"] @@ -165,6 +173,68 @@ def _get_default_denylist_standalone() -> list[str]: return common + ["bash", "sh", "nohup", "vi", "vim", "emacs", "nano", "su"] +_PATH_COMMANDS = { + "cat", + "cd", + "chmod", + "chown", + "cp", + "head", + "ls", + "mkdir", + "mv", + "rm", + "stat", + "tail", + "touch", + "wc", +} + + +def _collect_outside_dirs(command_parts: list[str]) -> set[str]: + """Collect parent directories referenced outside the workdir. + + Iterates file-manipulating commands (see _PATH_COMMANDS) and inspects + their arguments as candidate paths. Skips flags (-r, --recursive) and + chmod mode strings (+x). For any argument that resolves outside the current + working directory, adds the parent directory (or the path itself when it is + a directory) to the result set — suitable for building an OUTSIDE_DIRECTORY + RequiredPermission. + """ + dirs: set[str] = set() + for part in command_parts: + tokens = part.split() + command = tokens[0] if tokens else None + if not command or command not in _PATH_COMMANDS: + continue + for token in tokens[1:]: + # Skip CLI flags like -r, --recursive + if token.startswith("-"): + continue + # Skip chmod mode strings like +x, +rwx — they are not file paths + if command == "chmod" and token.startswith("+"): + continue + # Only consider tokens that look like paths + if not ( + token.startswith(os.sep) + or token.startswith("~") + or token.startswith(".") + or os.sep in token + ): + continue + if is_path_within_workdir(token): + continue + # Resolve relative / home-relative paths, then collect parent dir + resolved = Path(token).expanduser() + if not resolved.is_absolute(): + resolved = Path.cwd() / resolved + resolved = resolved.resolve() + # For a directory target use the dir itself; for a file use its parent + parent = str(resolved) if resolved.is_dir() else str(resolved.parent) + dirs.add(parent) + return dirs + + class BashToolConfig(BaseToolConfig): permission: ToolPermission = ToolPermission.ASK max_output_bytes: int = Field( @@ -185,6 +255,10 @@ class BashToolConfig(BaseToolConfig): default_factory=_get_default_denylist_standalone, description="Commands that are denied only when run without arguments", ) + sensitive_patterns: list[str] = Field( + default=["sudo"], + description="Command prefixes that always ASK regardless of arity approval.", + ) class BashArgs(BaseModel): @@ -224,7 +298,10 @@ class Bash( def get_status_text(cls) -> str: return "Running command" - def resolve_permission(self, args: BashArgs) -> ToolPermission | None: + def resolve_permission(self, args: BashArgs) -> PermissionContext | None: # noqa: PLR0911, PLR0912 + if is_windows(): + return None + command_parts = _extract_commands(args.command) if not command_parts: return None @@ -236,32 +313,92 @@ class Bash( parts = command.split() if not parts: return False - base_command = parts[0] - has_args = len(parts) > 1 - - if not has_args: + if len(parts) == 1: command_name = os.path.basename(base_command) if command_name in self.config.denylist_standalone: return True if base_command in self.config.denylist_standalone: return True - return False def is_allowlisted(command: str) -> bool: return any(command.startswith(pattern) for pattern in self.config.allowlist) + def is_sensitive(command: str) -> bool: + tokens = command.split() + if not tokens: + return False + return tokens[0] in self.config.sensitive_patterns + for part in command_parts: - if is_denylisted(part): - return ToolPermission.NEVER - if is_standalone_denylisted(part): - return ToolPermission.NEVER + if is_denylisted(part) or is_standalone_denylisted(part): + return PermissionContext(permission=ToolPermission.NEVER) - if all(is_allowlisted(part) for part in command_parts): - return ToolPermission.ALWAYS + if self.config.permission == ToolPermission.ALWAYS: + return PermissionContext(permission=ToolPermission.ALWAYS) - return None + has_sensitive = any(is_sensitive(part) for part in command_parts) + all_allowlisted = not has_sensitive and all( + is_allowlisted(part) for part in command_parts + ) + outside_dirs = _collect_outside_dirs(command_parts) + + if all_allowlisted and not outside_dirs: + return PermissionContext(permission=ToolPermission.ALWAYS) + + required: list[RequiredPermission] = [] + seen_session: set[str] = set() + + for part in command_parts: + if not part: + continue + tokens = part.split() + if not tokens: + continue + if not is_sensitive(part) and is_allowlisted(part): + continue + + if is_sensitive(part): + required.append( + RequiredPermission( + scope=PermissionScope.COMMAND_PATTERN, + invocation_pattern=part, + session_pattern=part, + label=part, + ) + ) + else: + session_pat = build_session_pattern(tokens) + if session_pat not in seen_session: + seen_session.add(session_pat) + required.append( + RequiredPermission( + scope=PermissionScope.COMMAND_PATTERN, + invocation_pattern=part, + session_pattern=session_pat, + label=session_pat, + ) + ) + + if outside_dirs: + globs = sorted(str(Path(d) / "*") for d in outside_dirs) + for glob in globs: + required.append( + RequiredPermission( + scope=PermissionScope.OUTSIDE_DIRECTORY, + invocation_pattern=glob, + session_pattern=glob, + label=f"outside workdir ({glob})", + ) + ) + + if not required: + return None + + return PermissionContext( + permission=ToolPermission.ASK, required_permissions=required + ) @final def _build_timeout_error(self, command: str, timeout: int) -> ToolError: diff --git a/vibe/core/tools/builtins/exit_plan_mode.py b/vibe/core/tools/builtins/exit_plan_mode.py index 82ec93d..1e40e4c 100644 --- a/vibe/core/tools/builtins/exit_plan_mode.py +++ b/vibe/core/tools/builtins/exit_plan_mode.py @@ -21,6 +21,7 @@ from vibe.core.tools.builtins.ask_user_question import ( Question, ) from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData +from vibe.core.utils.io import read_safe class ExitPlanModeArgs(BaseModel): @@ -74,7 +75,7 @@ class ExitPlanMode( plan_content: str | None = None if ctx.plan_file_path and ctx.plan_file_path.is_file(): try: - plan_content = ctx.plan_file_path.read_text() + plan_content = read_safe(ctx.plan_file_path) except OSError as e: raise ToolError( f"Failed to read plan file at {ctx.plan_file_path}: {e}" diff --git a/vibe/core/tools/builtins/grep.py b/vibe/core/tools/builtins/grep.py index 652274e..2b515e4 100644 --- a/vibe/core/tools/builtins/grep.py +++ b/vibe/core/tools/builtins/grep.py @@ -17,8 +17,11 @@ from vibe.core.tools.base import ( ToolError, ToolPermission, ) +from vibe.core.tools.permissions import PermissionContext from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData +from vibe.core.tools.utils import resolve_file_tool_permission from vibe.core.types import ToolStreamEvent +from vibe.core.utils.io import read_safe if TYPE_CHECKING: from vibe.core.types import ToolResultEvent @@ -31,6 +34,10 @@ class GrepBackend(StrEnum): class GrepToolConfig(BaseToolConfig): permission: ToolPermission = ToolPermission.ALWAYS + sensitive_patterns: list[str] = Field( + default=["**/.env", "**/.env.*"], + description="File patterns that trigger ASK even when permission is ALWAYS.", + ) max_output_bytes: int = Field( default=64_000, description="Hard cap for the total size of matched lines." @@ -103,6 +110,16 @@ class Grep( "Respects .gitignore and .codeignore files by default when using ripgrep." ) + def resolve_permission(self, args: GrepArgs) -> PermissionContext | None: + return resolve_file_tool_permission( + args.path, + tool_name=self.get_name(), + allowlist=self.config.allowlist, + denylist=self.config.denylist, + config_permission=self.config.permission, + sensitive_patterns=self.config.sensitive_patterns, + ) + def _detect_backend(self) -> GrepBackend: if shutil.which("rg"): return GrepBackend.RIPGREP @@ -150,7 +167,7 @@ class Grep( def _load_codeignore_patterns(self, codeignore_path: Path) -> list[str]: patterns = [] try: - content = codeignore_path.read_text("utf-8") + content = read_safe(codeignore_path) for line in content.splitlines(): line = line.strip() if line and not line.startswith("#"): diff --git a/vibe/core/tools/builtins/prompts/skill.md b/vibe/core/tools/builtins/prompts/skill.md new file mode 100644 index 0000000..1004d6c --- /dev/null +++ b/vibe/core/tools/builtins/prompts/skill.md @@ -0,0 +1,19 @@ +Use `skill` to load specialized skills that provide domain-specific instructions and workflows. + +## When to Use This Tool + +- When a task matches one of the available skills listed in your system prompt +- When the user references a skill by name (e.g., "use the review skill") +- When you need specialized workflows, templates, or scripts bundled with a skill + +## How It Works + +1. Call `skill` with the skill's `name` from the `` section +2. The tool returns the full skill instructions along with a list of bundled files +3. Follow the loaded instructions step by step — you are the executor + +## Notes + +- Skills may include bundled resources (scripts, references, templates) in their directory +- File paths in skill output are relative to the skill's base directory +- Each skill is loaded once per invocation — re-invoke if you need to reload diff --git a/vibe/core/tools/builtins/read_file.py b/vibe/core/tools/builtins/read_file.py index 0acc9f8..05af798 100644 --- a/vibe/core/tools/builtins/read_file.py +++ b/vibe/core/tools/builtins/read_file.py @@ -16,6 +16,7 @@ from vibe.core.tools.base import ( ToolError, ToolPermission, ) +from vibe.core.tools.permissions import PermissionContext from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData from vibe.core.tools.utils import resolve_file_tool_permission from vibe.core.types import ToolStreamEvent @@ -53,6 +54,10 @@ class ReadFileResult(BaseModel): class ReadFileToolConfig(BaseToolConfig): permission: ToolPermission = ToolPermission.ALWAYS + sensitive_patterns: list[str] = Field( + default=["**/.env", "**/.env.*"], + description="File patterns that trigger ASK even when permission is ALWAYS.", + ) max_read_bytes: int = Field( default=64_000, description="Maximum total bytes to read from a file in one go." @@ -87,12 +92,14 @@ class ReadFile( was_truncated=read_result.was_truncated, ) - def resolve_permission(self, args: ReadFileArgs) -> ToolPermission | None: + def resolve_permission(self, args: ReadFileArgs) -> PermissionContext | None: return resolve_file_tool_permission( args.path, + tool_name=self.get_name(), allowlist=self.config.allowlist, denylist=self.config.denylist, config_permission=self.config.permission, + sensitive_patterns=self.config.sensitive_patterns, ) def get_result_extra(self, result: ReadFileResult) -> str | None: @@ -127,13 +134,26 @@ class ReadFile( return file_path async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult: + try: + return await self._do_read_file(args, file_path, encoding="utf-8") + except (UnicodeDecodeError, ValueError): + return await self._do_read_file(args, file_path, errors="replace") + + async def _do_read_file( + self, + args: ReadFileArgs, + file_path: Path, + *, + encoding: str | None = None, + errors: str | None = None, + ) -> _ReadResult: try: lines_to_return: list[str] = [] bytes_read = 0 was_truncated = False async with await anyio.Path(file_path).open( - encoding="utf-8", errors="ignore" + encoding=encoding, errors=errors ) as f: line_index = 0 async for line in f: diff --git a/vibe/core/tools/builtins/search_replace.py b/vibe/core/tools/builtins/search_replace.py index 9c730eb..b797f4c 100644 --- a/vibe/core/tools/builtins/search_replace.py +++ b/vibe/core/tools/builtins/search_replace.py @@ -16,11 +16,12 @@ from vibe.core.tools.base import ( BaseToolState, InvokeContext, ToolError, - ToolPermission, ) +from vibe.core.tools.permissions import PermissionContext from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData from vibe.core.tools.utils import resolve_file_tool_permission from vibe.core.types import ToolResultEvent, ToolStreamEvent +from vibe.core.utils.io import read_safe_async SEARCH_REPLACE_BLOCK_RE = re.compile( r"<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE", flags=re.DOTALL @@ -65,6 +66,10 @@ class SearchReplaceResult(BaseModel): class SearchReplaceConfig(BaseToolConfig): + sensitive_patterns: list[str] = Field( + default=["**/.env", "**/.env.*"], + description="File patterns that trigger ASK even when permission is ALWAYS.", + ) max_content_size: int = 100_000 create_backup: bool = False fuzzy_threshold: float = 0.9 @@ -105,12 +110,14 @@ class SearchReplace( def get_status_text(cls) -> str: return "Editing files" - def resolve_permission(self, args: SearchReplaceArgs) -> ToolPermission | None: + def resolve_permission(self, args: SearchReplaceArgs) -> PermissionContext | None: return resolve_file_tool_permission( args.file_path, + tool_name=self.get_name(), allowlist=self.config.allowlist, denylist=self.config.denylist, config_permission=self.config.permission, + sensitive_patterns=self.config.sensitive_patterns, ) @final @@ -211,12 +218,11 @@ class SearchReplace( async def _read_file(self, file_path: Path) -> str: try: - async with await anyio.Path(file_path).open(encoding="utf-8") as f: - return await f.read() - except UnicodeDecodeError as e: - raise ToolError(f"Unicode decode error reading {file_path}: {e}") from e + return await read_safe_async(file_path, raise_on_error=True) except PermissionError: raise ToolError(f"Permission denied reading file: {file_path}") + except OSError as e: + raise ToolError(f"OS error reading {file_path}: {e}") from e except Exception as e: raise ToolError(f"Unexpected error reading {file_path}: {e}") from e diff --git a/vibe/core/tools/builtins/skill.py b/vibe/core/tools/builtins/skill.py new file mode 100644 index 0000000..3092104 --- /dev/null +++ b/vibe/core/tools/builtins/skill.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import ClassVar + +from pydantic import BaseModel, Field + +from vibe.core.skills.parser import SkillParseError, parse_frontmatter +from vibe.core.tools.base import ( + BaseTool, + BaseToolConfig, + BaseToolState, + InvokeContext, + ToolError, + ToolPermission, +) +from vibe.core.tools.permissions import ( + PermissionContext, + PermissionScope, + RequiredPermission, +) +from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData +from vibe.core.types import ToolResultEvent, ToolStreamEvent +from vibe.core.utils.io import read_safe + +_MAX_LISTED_FILES = 10 + + +class SkillArgs(BaseModel): + name: str = Field(description="The name of the skill to load from available_skills") + + +class SkillResult(BaseModel): + name: str = Field(description="The name of the loaded skill") + content: str = Field(description="The full skill content block") + skill_dir: str = Field(description="Absolute path to the skill directory") + + +class SkillToolConfig(BaseToolConfig): + permission: ToolPermission = ToolPermission.ASK + + +class Skill( + BaseTool[SkillArgs, SkillResult, SkillToolConfig, BaseToolState], + ToolUIData[SkillArgs, SkillResult], +): + description: ClassVar[str] = ( + "Load a specialized skill that provides domain-specific instructions and workflows. " + "When you recognize that a task matches one of the available skills listed in your system prompt, " + "use this tool to load the full skill instructions. " + "The skill will inject detailed instructions, workflows, and access to bundled resources " + "(scripts, references, templates) into the conversation context." + ) + + @classmethod + def format_call_display(cls, args: SkillArgs) -> ToolCallDisplay: + return ToolCallDisplay(summary=f"Loading skill: {args.name}") + + @classmethod + def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: + if event.error: + return ToolResultDisplay(success=False, message=event.error) + if not isinstance(event.result, SkillResult): + return ToolResultDisplay(success=True, message="Skill loaded") + return ToolResultDisplay( + success=True, message=f"Loaded skill: {event.result.name}" + ) + + @classmethod + def get_status_text(cls) -> str: + return "Loading skill" + + def resolve_permission(self, args: SkillArgs) -> PermissionContext | None: + return PermissionContext( + permission=self.config.permission, + required_permissions=[ + RequiredPermission( + scope=PermissionScope.FILE_PATTERN, + invocation_pattern=args.name, + session_pattern=args.name, + label=f"Load skill: {args.name}", + ) + ], + ) + + async def run( + self, args: SkillArgs, ctx: InvokeContext | None = None + ) -> AsyncGenerator[ToolStreamEvent | SkillResult, None]: + if ctx is None or ctx.skill_manager is None: + raise ToolError("Skill manager not available") + + skill_manager = ctx.skill_manager + skill_info = skill_manager.get_skill(args.name) + + if skill_info is None: + available = ", ".join(sorted(skill_manager.available_skills.keys())) + raise ToolError( + f'Skill "{args.name}" not found. Available skills: {available or "none"}' + ) + + try: + raw = read_safe(skill_info.skill_path) + _, body = parse_frontmatter(raw) + except (OSError, SkillParseError) as e: + raise ToolError(f"Cannot load skill file: {e}") from e + + skill_dir = skill_info.skill_dir + files: list[str] = [] + try: + for entry in sorted(skill_dir.rglob("*")): + if not entry.is_file(): + continue + if entry.name == "SKILL.md": + continue + files.append(str(entry.relative_to(skill_dir))) + if len(files) >= _MAX_LISTED_FILES: + break + except OSError: + pass + + file_lines = "\n".join(f"{f}" for f in files) + + output = "\n".join([ + f'', + f"# Skill: {args.name}", + "", + body.strip(), + "", + f"Base directory for this skill: {skill_dir}", + "Relative paths in this skill are relative to this base directory.", + "Note: file list is sampled.", + "", + "", + file_lines, + "", + "", + ]) + + yield SkillResult(name=args.name, content=output, skill_dir=str(skill_dir)) diff --git a/vibe/core/tools/builtins/task.py b/vibe/core/tools/builtins/task.py index bac6d81..e5b1ec2 100644 --- a/vibe/core/tools/builtins/task.py +++ b/vibe/core/tools/builtins/task.py @@ -17,6 +17,7 @@ from vibe.core.tools.base import ( ToolError, ToolPermission, ) +from vibe.core.tools.permissions import PermissionContext from vibe.core.tools.ui import ( ToolCallDisplay, ToolResultDisplay, @@ -89,16 +90,16 @@ class Task( def get_status_text(cls) -> str: return "Running subagent" - def resolve_permission(self, args: TaskArgs) -> ToolPermission | None: + def resolve_permission(self, args: TaskArgs) -> PermissionContext | None: agent_name = args.agent for pattern in self.config.denylist: if fnmatch.fnmatch(agent_name, pattern): - return ToolPermission.NEVER + return PermissionContext(permission=ToolPermission.NEVER) for pattern in self.config.allowlist: if fnmatch.fnmatch(agent_name, pattern): - return ToolPermission.ALWAYS + return PermissionContext(permission=ToolPermission.ALWAYS) return None diff --git a/vibe/core/tools/builtins/webfetch.py b/vibe/core/tools/builtins/webfetch.py index 9f9a325..945bdf2 100644 --- a/vibe/core/tools/builtins/webfetch.py +++ b/vibe/core/tools/builtins/webfetch.py @@ -16,6 +16,11 @@ from vibe.core.tools.base import ( ToolError, ToolPermission, ) +from vibe.core.tools.permissions import ( + PermissionContext, + PermissionScope, + RequiredPermission, +) from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData from vibe.core.types import ToolStreamEvent @@ -71,14 +76,43 @@ class WebFetch( "Fetch content from a URL. Converts HTML to markdown for readability." ) + @staticmethod + def _normalize_url(url: str) -> str: + """Normalise a URL to always have an http(s) scheme. + + Handles protocol-relative URLs (//example.com) and bare URLs (example.com). + """ + raw = url.lstrip("/") if url.startswith("//") else url + return raw if raw.startswith(("http://", "https://")) else "https://" + raw + + def resolve_permission(self, args: WebFetchArgs) -> PermissionContext | None: + if self.config.permission in {ToolPermission.ALWAYS, ToolPermission.NEVER}: + return PermissionContext(permission=self.config.permission) + + parsed = urlparse(self._normalize_url(args.url)) + domain = parsed.netloc or parsed.path.split("/")[0] + if not domain: + return None + + return PermissionContext( + permission=ToolPermission.ASK, + required_permissions=[ + RequiredPermission( + scope=PermissionScope.URL_PATTERN, + invocation_pattern=domain, + session_pattern=domain, + label=f"fetching from {domain}", + ) + ], + ) + @final async def run( self, args: WebFetchArgs, ctx: InvokeContext | None = None ) -> AsyncGenerator[ToolStreamEvent | WebFetchResult, None]: self._validate_args(args) - raw = args.url.lstrip("/") if args.url.startswith("//") else args.url - url = raw if raw.startswith(("http://", "https://")) else "https://" + raw + url = self._normalize_url(args.url) timeout = self._resolve_timeout(args.timeout) content, content_type = await self._fetch_url(url, timeout) diff --git a/vibe/core/tools/builtins/websearch.py b/vibe/core/tools/builtins/websearch.py index d79c183..a47ee5b 100644 --- a/vibe/core/tools/builtins/websearch.py +++ b/vibe/core/tools/builtins/websearch.py @@ -14,7 +14,6 @@ from mistralai.client.models import ( ) from pydantic import BaseModel, Field -from vibe.core.config import Backend from vibe.core.tools.base import ( BaseTool, BaseToolConfig, @@ -24,7 +23,7 @@ from vibe.core.tools.base import ( ToolPermission, ) from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData -from vibe.core.types import ToolStreamEvent +from vibe.core.types import Backend, ToolStreamEvent from vibe.core.utils import get_server_url_from_api_base if TYPE_CHECKING: diff --git a/vibe/core/tools/builtins/write_file.py b/vibe/core/tools/builtins/write_file.py index 3078125..99d3f85 100644 --- a/vibe/core/tools/builtins/write_file.py +++ b/vibe/core/tools/builtins/write_file.py @@ -15,6 +15,7 @@ from vibe.core.tools.base import ( ToolError, ToolPermission, ) +from vibe.core.tools.permissions import PermissionContext from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData from vibe.core.tools.utils import resolve_file_tool_permission from vibe.core.types import ToolResultEvent, ToolStreamEvent @@ -37,6 +38,10 @@ class WriteFileResult(BaseModel): class WriteFileConfig(BaseToolConfig): permission: ToolPermission = ToolPermission.ASK + sensitive_patterns: list[str] = Field( + default=["**/.env", "**/.env.*"], + description="File patterns that trigger ASK even when permission is ALWAYS.", + ) max_write_bytes: int = 64_000 create_parent_dirs: bool = True @@ -70,12 +75,14 @@ class WriteFile( def get_status_text(cls) -> str: return "Writing file" - def resolve_permission(self, args: WriteFileArgs) -> ToolPermission | None: + def resolve_permission(self, args: WriteFileArgs) -> PermissionContext | None: return resolve_file_tool_permission( args.path, + tool_name=self.get_name(), allowlist=self.config.allowlist, denylist=self.config.denylist, config_permission=self.config.permission, + sensitive_patterns=self.config.sensitive_patterns, ) @final diff --git a/vibe/core/tools/manager.py b/vibe/core/tools/manager.py index 9351796..74164ae 100644 --- a/vibe/core/tools/manager.py +++ b/vibe/core/tools/manager.py @@ -221,10 +221,9 @@ class ToolManager: user_overrides = self._config.tools.get(tool_name) if user_overrides is None: - merged_dict = default_config.model_dump() - else: - merged_dict = {**default_config.model_dump(), **user_overrides.model_dump()} + return config_class() + merged_dict = {**default_config.model_dump(), **user_overrides} return config_class.model_validate(merged_dict) def get(self, tool_name: str) -> BaseTool: diff --git a/vibe/core/tools/permissions.py b/vibe/core/tools/permissions.py new file mode 100644 index 0000000..357a619 --- /dev/null +++ b/vibe/core/tools/permissions.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from enum import StrEnum, auto + +from pydantic import BaseModel, Field + +from vibe.core.tools.base import ToolPermission + + +class PermissionScope(StrEnum): + COMMAND_PATTERN = auto() + OUTSIDE_DIRECTORY = auto() + FILE_PATTERN = auto() + URL_PATTERN = auto() + + +class RequiredPermission(BaseModel): + scope: PermissionScope + invocation_pattern: str + session_pattern: str + label: str + + +class PermissionContext(BaseModel): + permission: ToolPermission + required_permissions: list[RequiredPermission] = Field(default_factory=list) + + +class ApprovedRule(BaseModel): + tool_name: str + scope: PermissionScope + session_pattern: str diff --git a/vibe/core/tools/utils.py b/vibe/core/tools/utils.py index 6d99a69..2373e27 100644 --- a/vibe/core/tools/utils.py +++ b/vibe/core/tools/utils.py @@ -1,41 +1,59 @@ from __future__ import annotations import fnmatch -from pathlib import Path +from pathlib import Path, PurePath from vibe.core.tools.base import ToolPermission +from vibe.core.tools.permissions import ( + PermissionContext, + PermissionScope, + RequiredPermission, +) + + +def wildcard_match(text: str, pattern: str) -> bool: + """Match text against a wildcard pattern using fnmatch. + + If pattern ends with " *", trailing part is optional (matches with or without args). + """ + if fnmatch.fnmatch(text, pattern): + return True + if pattern.endswith(" *") and fnmatch.fnmatch(text, pattern[:-2]): + return True + return False + + +def _make_absolute(path_str: str) -> Path: + path = Path(path_str).expanduser() + if not path.is_absolute(): + path = Path.cwd() / path + return path def resolve_path_permission( path_str: str, *, allowlist: list[str], denylist: list[str] -) -> ToolPermission | None: +) -> PermissionContext | None: """Resolve permission for a file path against glob patterns. Returns NEVER on denylist match, ALWAYS on allowlist match, None otherwise. """ - file_path = Path(path_str).expanduser() - if not file_path.is_absolute(): - file_path = Path.cwd() / file_path - file_str = str(file_path.resolve()) + file_str = str(_make_absolute(path_str).resolve()) for pattern in denylist: if fnmatch.fnmatch(file_str, pattern): - return ToolPermission.NEVER + return PermissionContext(permission=ToolPermission.NEVER) for pattern in allowlist: if fnmatch.fnmatch(file_str, pattern): - return ToolPermission.ALWAYS + return PermissionContext(permission=ToolPermission.ALWAYS) return None def is_path_within_workdir(path_str: str) -> bool: """Return True if the resolved path is inside cwd.""" - file_path = Path(path_str).expanduser() - if not file_path.is_absolute(): - file_path = Path.cwd() / file_path try: - file_path.resolve().relative_to(Path.cwd().resolve()) + _make_absolute(path_str).resolve().relative_to(Path.cwd().resolve()) return True except ValueError: return False @@ -44,15 +62,16 @@ def is_path_within_workdir(path_str: str) -> bool: def resolve_file_tool_permission( path_str: str, *, + tool_name: str, allowlist: list[str], denylist: list[str], config_permission: ToolPermission, -) -> ToolPermission | None: + sensitive_patterns: list[str], +) -> PermissionContext | None: """Resolve permission for a file-based tool invocation. - Checks allowlist/denylist first, then escalates to ASK for paths outside - the working directory (unless the tool is configured as NEVER). - Returns None to fall back to the tool's default config permission. + Checks allowlist/denylist, then sensitive patterns, then workdir boundary. + Returns PermissionContext with granular required_permissions when applicable. """ if ( result := resolve_path_permission( @@ -61,9 +80,41 @@ def resolve_file_tool_permission( ) is not None: return result + required: list[RequiredPermission] = [] + + file_path = _make_absolute(path_str) + file_str = str(file_path.resolve()) + + for pattern in sensitive_patterns: + if PurePath(file_str).match(pattern): + required.append( + RequiredPermission( + scope=PermissionScope.FILE_PATTERN, + invocation_pattern=file_path.name, + session_pattern="*", + label=f"accessing sensitive files ({tool_name})", + ) + ) + break + if not is_path_within_workdir(path_str): if config_permission == ToolPermission.NEVER: - return ToolPermission.NEVER - return ToolPermission.ASK + return PermissionContext(permission=ToolPermission.NEVER) + resolved = file_path.resolve() + parent_dir = str(resolved.parent) + glob = str(Path(parent_dir) / "*") + required.append( + RequiredPermission( + scope=PermissionScope.OUTSIDE_DIRECTORY, + invocation_pattern=glob, + session_pattern=glob, + label=f"outside workdir ({glob})", + ) + ) + + if required: + return PermissionContext( + permission=ToolPermission.ASK, required_permissions=required + ) return None diff --git a/vibe/core/tracing.py b/vibe/core/tracing.py new file mode 100644 index 0000000..22740ee --- /dev/null +++ b/vibe/core/tracing.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import atexit +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any + +from opentelemetry import baggage, context, trace +from opentelemetry.semconv._incubating.attributes import gen_ai_attributes +from opentelemetry.trace import StatusCode + +from vibe import __version__ + +if TYPE_CHECKING: + from vibe.core.config import VibeConfig + +from vibe.core.logger import logger + +VIBE_TRACER_NAME = "mistral_vibe" +VIBE_AGENT_NAME = "mistral-vibe" + + +def setup_tracing(config: VibeConfig) -> None: + if not config.enable_otel: + return + + exporter_cfg = config.otel_exporter_config + if exporter_cfg is None: + return + + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + resource = Resource.create({ + "service.name": VIBE_AGENT_NAME, + "service.version": __version__, + }) + exporter = OTLPSpanExporter(**exporter_cfg.model_dump()) + provider = TracerProvider(resource=resource) + provider.add_span_processor(BatchSpanProcessor(exporter)) + trace.set_tracer_provider(provider) + atexit.register(provider.shutdown) + + +def _get_tracer() -> trace.Tracer: + return trace.get_tracer(VIBE_TRACER_NAME, __version__) + + +@asynccontextmanager +async def _safe_span( + name: str, attributes: dict[str, Any] +) -> AsyncGenerator[trace.Span]: + # Tracing errors are logged, never raised. + try: + tracer = _get_tracer() + cm = tracer.start_as_current_span(name, attributes=attributes) + span = cm.__enter__() + except Exception: + logger.warning("Failed to create span", exc_info=True) + yield trace.INVALID_SPAN + return + + exc_info: BaseException | None = None + try: + yield span + except BaseException as exc: + exc_info = exc + raise + finally: + try: + if isinstance(exc_info, Exception): + span.set_status(StatusCode.ERROR, str(exc_info)) + span.record_exception(exc_info) + elif exc_info is None: + span.set_status(StatusCode.OK) + except Exception: + logger.warning("Failed to record span status", exc_info=True) + finally: + try: + cm.__exit__(None, None, None) + except Exception: + logger.warning("Failed to end span", exc_info=True) + + +@asynccontextmanager +async def agent_span( + *, model: str | None = None, session_id: str | None = None +) -> AsyncGenerator[trace.Span]: + attributes: dict[str, Any] = { + gen_ai_attributes.GEN_AI_OPERATION_NAME: gen_ai_attributes.GenAiOperationNameValues.INVOKE_AGENT.value, + gen_ai_attributes.GEN_AI_PROVIDER_NAME: gen_ai_attributes.GenAiProviderNameValues.MISTRAL_AI.value, + gen_ai_attributes.GEN_AI_AGENT_NAME: VIBE_AGENT_NAME, + } + if model: + attributes[gen_ai_attributes.GEN_AI_REQUEST_MODEL] = model + if session_id: + attributes[gen_ai_attributes.GEN_AI_CONVERSATION_ID] = session_id + + # Propagate conversation ID as OTEL baggage so descendant spans — including + # those created by the Mistral SDK — can read and attach it. + token = None + if session_id: + ctx = baggage.set_baggage(gen_ai_attributes.GEN_AI_CONVERSATION_ID, session_id) + token = context.attach(ctx) + try: + async with _safe_span(f"invoke_agent {VIBE_AGENT_NAME}", attributes) as span: + yield span + finally: + if token is not None: + context.detach(token) + + +@asynccontextmanager +async def tool_span( + *, tool_name: str, call_id: str, arguments: str +) -> AsyncGenerator[trace.Span]: + attributes: dict[str, Any] = { + gen_ai_attributes.GEN_AI_OPERATION_NAME: gen_ai_attributes.GenAiOperationNameValues.EXECUTE_TOOL.value, + gen_ai_attributes.GEN_AI_TOOL_NAME: tool_name, + gen_ai_attributes.GEN_AI_TOOL_CALL_ID: call_id, + gen_ai_attributes.GEN_AI_TOOL_CALL_ARGUMENTS: arguments, + gen_ai_attributes.GEN_AI_TOOL_TYPE: "function", + } + if conv_id := baggage.get_baggage(gen_ai_attributes.GEN_AI_CONVERSATION_ID): + attributes[gen_ai_attributes.GEN_AI_CONVERSATION_ID] = conv_id + + async with _safe_span(f"execute_tool {tool_name}", attributes) as span: + yield span + + +def set_tool_result(span: trace.Span, result: str) -> None: + try: + span.set_attribute(gen_ai_attributes.GEN_AI_TOOL_CALL_RESULT, result) + except Exception: + pass diff --git a/vibe/core/transcribe/mistral_transcribe_client.py b/vibe/core/transcribe/mistral_transcribe_client.py index 4b460ec..6e82e11 100644 --- a/vibe/core/transcribe/mistral_transcribe_client.py +++ b/vibe/core/transcribe/mistral_transcribe_client.py @@ -52,7 +52,7 @@ class MistralTranscribeClient: target_streaming_delay_ms=self._target_streaming_delay_ms, ): if isinstance(event, RealtimeTranscriptionSessionCreated): - yield TranscribeSessionCreated() + yield TranscribeSessionCreated(request_id=event.session.request_id) elif isinstance(event, TranscriptionStreamTextDelta): yield TranscribeTextDelta(text=event.text) elif isinstance(event, TranscriptionStreamDone): diff --git a/vibe/core/transcribe/transcribe_client_port.py b/vibe/core/transcribe/transcribe_client_port.py index a482b45..963158b 100644 --- a/vibe/core/transcribe/transcribe_client_port.py +++ b/vibe/core/transcribe/transcribe_client_port.py @@ -9,7 +9,7 @@ from vibe.core.config import TranscribeModelConfig, TranscribeProviderConfig @dataclass(frozen=True, slots=True) class TranscribeSessionCreated: - pass + request_id: str @dataclass(frozen=True, slots=True) diff --git a/vibe/core/trusted_folders.py b/vibe/core/trusted_folders.py index f6169c7..5c4a21b 100644 --- a/vibe/core/trusted_folders.py +++ b/vibe/core/trusted_folders.py @@ -8,7 +8,7 @@ import tomli_w from vibe.core.paths import ( AGENTS_MD_FILENAME, TRUSTED_FOLDERS_FILE, - walk_local_config_dirs_all, + has_config_dirs_nearby, ) @@ -19,8 +19,9 @@ def has_agents_md_file(path: Path) -> bool: def has_trustable_content(path: Path) -> bool: if (path / ".vibe").exists(): return True - tools_dirs, skills_dirs, agents_dirs = walk_local_config_dirs_all(path) - return bool(tools_dirs or skills_dirs or agents_dirs) or has_agents_md_file(path) + if has_agents_md_file(path): + return True + return has_config_dirs_nearby(path) class TrustedFoldersManager: diff --git a/vibe/core/tts/__init__.py b/vibe/core/tts/__init__.py new file mode 100644 index 0000000..6f0b4a4 --- /dev/null +++ b/vibe/core/tts/__init__.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from vibe.core.tts.factory import make_tts_client +from vibe.core.tts.mistral_tts_client import MistralTTSClient +from vibe.core.tts.tts_client_port import TTSClientPort, TTSResult + +__all__ = ["MistralTTSClient", "TTSClientPort", "TTSResult", "make_tts_client"] diff --git a/vibe/core/tts/factory.py b/vibe/core/tts/factory.py new file mode 100644 index 0000000..71804f8 --- /dev/null +++ b/vibe/core/tts/factory.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from vibe.core.config import TTSClient, TTSModelConfig, TTSProviderConfig +from vibe.core.tts.mistral_tts_client import MistralTTSClient +from vibe.core.tts.tts_client_port import TTSClientPort + +TTS_CLIENT_MAP: dict[TTSClient, type[TTSClientPort]] = { + TTSClient.MISTRAL: MistralTTSClient +} + + +def make_tts_client( + provider: TTSProviderConfig, model: TTSModelConfig +) -> TTSClientPort: + return TTS_CLIENT_MAP[provider.client](provider=provider, model=model) diff --git a/vibe/core/tts/mistral_tts_client.py b/vibe/core/tts/mistral_tts_client.py new file mode 100644 index 0000000..ce79f55 --- /dev/null +++ b/vibe/core/tts/mistral_tts_client.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import base64 +import os + +import httpx + +from vibe.core.config import TTSModelConfig, TTSProviderConfig +from vibe.core.tts.tts_client_port import TTSResult + + +class MistralTTSClient: + def __init__(self, provider: TTSProviderConfig, model: TTSModelConfig) -> None: + self._model_name = model.name + self._voice = model.voice + self._response_format = model.response_format + self._client = httpx.AsyncClient( + base_url=f"{provider.api_base}/v1", + headers={ + "Authorization": f"Bearer {os.getenv(provider.api_key_env_var, '')}", + "Content-Type": "application/json", + }, + timeout=60.0, + ) + + async def speak(self, text: str) -> TTSResult: + response = await self._client.post( + "/audio/speech", + json={ + "model": self._model_name, + "input": text, + "voice_id": self._voice, + "stream": False, + "response_format": self._response_format, + }, + ) + response.raise_for_status() + + data = response.json() + audio_bytes = base64.b64decode(data["audio_data"]) + return TTSResult(audio_data=audio_bytes) + + async def close(self) -> None: + await self._client.aclose() diff --git a/vibe/core/tts/tts_client_port.py b/vibe/core/tts/tts_client_port.py new file mode 100644 index 0000000..970b661 --- /dev/null +++ b/vibe/core/tts/tts_client_port.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from vibe.core.config import TTSModelConfig, TTSProviderConfig + + +@dataclass(frozen=True, slots=True) +class TTSResult: + audio_data: bytes + + +class TTSClientPort(Protocol): + def __init__(self, provider: TTSProviderConfig, model: TTSModelConfig) -> None: ... + + async def speak(self, text: str) -> TTSResult: ... + + async def close(self) -> None: ... diff --git a/vibe/core/types.py b/vibe/core/types.py index 0998178..a57f2fc 100644 --- a/vibe/core/types.py +++ b/vibe/core/types.py @@ -11,6 +11,7 @@ from uuid import uuid4 if TYPE_CHECKING: from vibe.core.tools.base import BaseTool + from vibe.core.tools.permissions import RequiredPermission else: BaseTool = Any @@ -25,6 +26,11 @@ from pydantic import ( ) +class Backend(StrEnum): + MISTRAL = auto() + GENERIC = auto() + + class AgentStats(BaseModel): steps: int = 0 session_prompt_tokens: int = 0 @@ -314,13 +320,18 @@ class LLMChunk(BaseModel): model_config = ConfigDict(frozen=True) message: LLMMessage usage: LLMUsage | None = None + correlation_id: str | None = None def __add__(self, other: LLMChunk) -> LLMChunk: if self.usage is None and other.usage is None: new_usage = None else: new_usage = (self.usage or LLMUsage()) + (other.usage or LLMUsage()) - return LLMChunk(message=self.message + other.message, usage=new_usage) + return LLMChunk( + message=self.message + other.message, + usage=new_usage, + correlation_id=other.correlation_id or self.correlation_id, + ) class BaseEvent(BaseModel, ABC): @@ -398,6 +409,12 @@ class CompactEndEvent(BaseEvent): tool_call_id: str +class AgentProfileChangedEvent(BaseEvent): + """Emitted when the active agent profile changes during a turn.""" + + agent_name: str + + class OutputFormat(StrEnum): TEXT = auto() JSON = auto() @@ -405,9 +422,11 @@ class OutputFormat(StrEnum): type ApprovalCallback = Callable[ - [str, BaseModel, str], Awaitable[tuple[ApprovalResponse, str | None]] + [str, BaseModel, str, list[RequiredPermission] | None], + Awaitable[tuple[ApprovalResponse, str | None]], ] + type UserInputCallback = Callable[[BaseModel], Awaitable[BaseModel]] type SwitchAgentCallback = Callable[[str], Awaitable[None]] diff --git a/vibe/core/utils.py b/vibe/core/utils.py deleted file mode 100644 index 56c4e36..0000000 --- a/vibe/core/utils.py +++ /dev/null @@ -1,343 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine -import concurrent.futures -from datetime import UTC, datetime -from enum import Enum, auto -from fnmatch import fnmatch -import functools -from pathlib import Path -import re -import sys -from typing import Any - -import httpx - -from vibe import __version__ -from vibe.core.config import Backend -from vibe.core.types import BaseEvent, ToolResultEvent - -CANCELLATION_TAG = "user_cancellation" -TOOL_ERROR_TAG = "tool_error" -VIBE_STOP_EVENT_TAG = "vibe_stop_event" -VIBE_WARNING_TAG = "vibe_warning" - -KNOWN_TAGS = [CANCELLATION_TAG, TOOL_ERROR_TAG, VIBE_STOP_EVENT_TAG, VIBE_WARNING_TAG] - - -class TaggedText: - _TAG_PATTERN = re.compile( - rf"<({'|'.join(re.escape(tag) for tag in KNOWN_TAGS)})>(.*?)", - flags=re.DOTALL, - ) - - def __init__(self, message: str, tag: str = "") -> None: - self.message = message - self.tag = tag - - def __str__(self) -> str: - if not self.tag: - return self.message - return f"<{self.tag}>{self.message}" - - @staticmethod - def from_string(text: str) -> TaggedText: - found_tag = "" - result = text - - def replace_tag(match: re.Match[str]) -> str: - nonlocal found_tag - tag_name = match.group(1) - content = match.group(2) - if not found_tag: - found_tag = tag_name - return content - - result = TaggedText._TAG_PATTERN.sub(replace_tag, text) - - if found_tag: - return TaggedText(result, found_tag) - - return TaggedText(text, "") - - -class CancellationReason(Enum): - OPERATION_CANCELLED = auto() - TOOL_INTERRUPTED = auto() - TOOL_NO_RESPONSE = auto() - TOOL_SKIPPED = auto() - - -def get_user_cancellation_message( - cancellation_reason: CancellationReason, tool_name: str | None = None -) -> TaggedText: - match cancellation_reason: - case CancellationReason.OPERATION_CANCELLED: - return TaggedText("User cancelled the operation.", CANCELLATION_TAG) - case CancellationReason.TOOL_INTERRUPTED: - return TaggedText("Tool execution interrupted by user.", CANCELLATION_TAG) - case CancellationReason.TOOL_NO_RESPONSE: - return TaggedText( - "Tool execution interrupted - no response available", CANCELLATION_TAG - ) - case CancellationReason.TOOL_SKIPPED: - return TaggedText( - tool_name or "Tool execution skipped by user.", CANCELLATION_TAG - ) - - -def is_user_cancellation_event(event: BaseEvent) -> bool: - if not isinstance(event, ToolResultEvent): - return False - return event.cancelled - - -def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]: - """Check if the current directory is a dangerous folder that would cause - issues if we were to run the tool there. - - Args: - path: Path to check (defaults to current directory) - - Returns: - tuple[bool, str]: (is_dangerous, reason) where reason explains why it's dangerous - """ - path = Path(path).resolve() - - home_dir = Path.home() - - dangerous_paths = { - home_dir: "home directory", - home_dir / "Documents": "Documents folder", - home_dir / "Desktop": "Desktop folder", - home_dir / "Downloads": "Downloads folder", - home_dir / "Pictures": "Pictures folder", - home_dir / "Movies": "Movies folder", - home_dir / "Music": "Music folder", - home_dir / "Library": "Library folder", - Path("/Applications"): "Applications folder", - Path("/System"): "System folder", - Path("/Library"): "System Library folder", - Path("/usr"): "System usr folder", - Path("/private"): "System private folder", - } - - for dangerous_path, description in dangerous_paths.items(): - try: - if path == dangerous_path: - return True, f"You are in the {description}" - except (OSError, ValueError): - continue - return False, "" - - -def get_user_agent(backend: Backend | None) -> str: - user_agent = f"Mistral-Vibe/{__version__}" - if backend == Backend.MISTRAL: - mistral_sdk_prefix = "mistral-client-python/" - user_agent = f"{mistral_sdk_prefix}{user_agent}" - return user_agent - - -def _is_retryable_http_error(e: Exception) -> bool: - if isinstance(e, httpx.HTTPStatusError): - return e.response.status_code in {408, 409, 425, 429, 500, 502, 503, 504} - return False - - -def async_retry[T, **P]( - tries: int = 3, - delay_seconds: float = 0.5, - backoff_factor: float = 2.0, - is_retryable: Callable[[Exception], bool] = _is_retryable_http_error, -) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]: - """Args: - tries: Number of retry attempts - delay_seconds: Initial delay between retries in seconds - backoff_factor: Multiplier for delay on each retry - is_retryable: Function to determine if an exception should trigger a retry - (defaults to checking for retryable HTTP errors from both urllib and httpx) - - Returns: - Decorated function with retry logic - """ - - def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]: - @functools.wraps(func) - async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: - last_exc = None - for attempt in range(tries): - try: - return await func(*args, **kwargs) - except Exception as e: - last_exc = e - if attempt < tries - 1 and is_retryable(e): - current_delay = (delay_seconds * (backoff_factor**attempt)) + ( - 0.05 * attempt - ) - await asyncio.sleep(current_delay) - continue - raise e - raise RuntimeError( - f"Retries exhausted. Last error: {last_exc}" - ) from last_exc - - return wrapper - - return decorator - - -def async_generator_retry[T, **P]( - tries: int = 3, - delay_seconds: float = 0.5, - backoff_factor: float = 2.0, - is_retryable: Callable[[Exception], bool] = _is_retryable_http_error, -) -> Callable[[Callable[P, AsyncGenerator[T]]], Callable[P, AsyncGenerator[T]]]: - """Retry decorator for async generators. - - Args: - tries: Number of retry attempts - delay_seconds: Initial delay between retries in seconds - backoff_factor: Multiplier for delay on each retry - is_retryable: Function to determine if an exception should trigger a retry - (defaults to checking for retryable HTTP errors from both urllib and httpx) - - Returns: - Decorated async generator function with retry logic - """ - - def decorator( - func: Callable[P, AsyncGenerator[T]], - ) -> Callable[P, AsyncGenerator[T]]: - @functools.wraps(func) - async def wrapper(*args: P.args, **kwargs: P.kwargs) -> AsyncGenerator[T]: - last_exc = None - for attempt in range(tries): - try: - async for item in func(*args, **kwargs): - yield item - return - except Exception as e: - last_exc = e - if attempt < tries - 1 and is_retryable(e): - current_delay = (delay_seconds * (backoff_factor**attempt)) + ( - 0.05 * attempt - ) - await asyncio.sleep(current_delay) - continue - raise e - raise RuntimeError( - f"Retries exhausted. Last error: {last_exc}" - ) from last_exc - - return wrapper - - return decorator - - -class ConversationLimitException(Exception): - pass - - -def run_sync[T](coro: Coroutine[Any, Any, T]) -> T: - """Run an async coroutine synchronously, handling nested event loops. - - If called from within an async context (running event loop), runs the - coroutine in a thread pool executor. Otherwise, uses asyncio.run(). - - This mirrors the pattern used by ToolManager for MCP integration. - """ - try: - asyncio.get_running_loop() - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: - future = executor.submit(asyncio.run, coro) - return future.result() - except RuntimeError: - return asyncio.run(coro) - - -def is_windows() -> bool: - return sys.platform == "win32" - - -@functools.lru_cache(maxsize=256) -def _compile_icase(expr: str) -> re.Pattern[str] | None: - try: - return re.compile(expr, re.IGNORECASE) - except re.error: - return None - - -def name_matches(name: str, patterns: list[str]) -> bool: - """Check if a name matches any of the provided patterns. - - Supports two forms (case-insensitive): - - Glob wildcards using fnmatch (e.g., 'serena_*') - - Regex when prefixed with 're:' (e.g., 're:serena.*') - """ - n = name.lower() - for raw in patterns: - if not (p := (raw or "").strip()): - continue - - if p.startswith("re:"): - rx = _compile_icase(p.removeprefix("re:")) - if rx is not None and rx.fullmatch(name) is not None: - return True - elif fnmatch(n, p.lower()): - return True - - return False - - -class AsyncExecutor: - """Run sync functions in a thread pool with timeout. Supports async context manager.""" - - def __init__( - self, max_workers: int = 4, timeout: float = 60.0, name: str = "async-executor" - ) -> None: - self._executor = concurrent.futures.ThreadPoolExecutor( - max_workers=max_workers, thread_name_prefix=name - ) - self._timeout = timeout - - async def __aenter__(self) -> AsyncExecutor: - return self - - async def __aexit__(self, *_: object) -> None: - self.shutdown(wait=False) - - async def run[T](self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T: - loop = asyncio.get_running_loop() - future = loop.run_in_executor( - self._executor, functools.partial(fn, *args, **kwargs) - ) - try: - return await asyncio.wait_for(future, timeout=self._timeout) - except TimeoutError as e: - raise TimeoutError(f"Operation timed out after {self._timeout}s") from e - - def shutdown(self, wait: bool = True) -> None: - self._executor.shutdown(wait=wait) - - -def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> str: - if old_tokens is None or new_tokens is None: - return "Compaction complete" - - reduction = old_tokens - new_tokens - reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0 - return ( - f"Compaction complete: {old_tokens:,} → " - f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)" - ) - - -def get_server_url_from_api_base(api_base: str) -> str | None: - match = re.match(r"(https?://[^/]+)(/v.*)", api_base) - return match.group(1) if match else None - - -def utc_now() -> datetime: - return datetime.now(UTC) diff --git a/vibe/core/utils/__init__.py b/vibe/core/utils/__init__.py new file mode 100644 index 0000000..16c78c6 --- /dev/null +++ b/vibe/core/utils/__init__.py @@ -0,0 +1,55 @@ +"""Utilities package. Re-exports all public and test-used symbols from submodules. + +Import read_safe/read_safe_async from vibe.core.utils.io and create_slug from +vibe.core.utils.slug when needed to avoid circular imports with config. +""" + +from __future__ import annotations + +from vibe.core.utils.concurrency import ( + AsyncExecutor, + ConversationLimitException, + run_sync, +) +from vibe.core.utils.display import compact_reduction_display +from vibe.core.utils.http import get_server_url_from_api_base, get_user_agent +from vibe.core.utils.matching import name_matches +from vibe.core.utils.paths import is_dangerous_directory +from vibe.core.utils.platform import is_windows +from vibe.core.utils.retry import async_generator_retry, async_retry +from vibe.core.utils.tags import ( + CANCELLATION_TAG, + KNOWN_TAGS, + TOOL_ERROR_TAG, + VIBE_STOP_EVENT_TAG, + VIBE_WARNING_TAG, + CancellationReason, + TaggedText, + get_user_cancellation_message, + is_user_cancellation_event, +) +from vibe.core.utils.time import utc_now + +__all__ = [ + "CANCELLATION_TAG", + "KNOWN_TAGS", + "TOOL_ERROR_TAG", + "VIBE_STOP_EVENT_TAG", + "VIBE_WARNING_TAG", + "AsyncExecutor", + "CancellationReason", + "ConversationLimitException", + "TaggedText", + "async_generator_retry", + "async_retry", + "compact_reduction_display", + "get_server_url_from_api_base", + "get_user_agent", + "get_user_cancellation_message", + "is_dangerous_directory", + "is_user_cancellation_event", + "is_windows", + "name_matches", + "run_sync", + "utc_now", +] diff --git a/vibe/core/utils/concurrency.py b/vibe/core/utils/concurrency.py new file mode 100644 index 0000000..3d1de8d --- /dev/null +++ b/vibe/core/utils/concurrency.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine +import concurrent.futures +import functools +from typing import Any + + +class ConversationLimitException(Exception): + pass + + +def run_sync[T](coro: Coroutine[Any, Any, T]) -> T: + """Run an async coroutine synchronously, handling nested event loops. + + If called from within an async context (running event loop), runs the + coroutine in a thread pool executor. Otherwise, uses asyncio.run(). + + This mirrors the pattern used by ToolManager for MCP integration. + """ + try: + asyncio.get_running_loop() + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(asyncio.run, coro) + return future.result() + except RuntimeError: + return asyncio.run(coro) + + +class AsyncExecutor: + """Run sync functions in a thread pool with timeout. Supports async context manager.""" + + def __init__( + self, max_workers: int = 4, timeout: float = 60.0, name: str = "async-executor" + ) -> None: + self._executor = concurrent.futures.ThreadPoolExecutor( + max_workers=max_workers, thread_name_prefix=name + ) + self._timeout = timeout + + async def __aenter__(self) -> AsyncExecutor: + return self + + async def __aexit__(self, *_: object) -> None: + self.shutdown(wait=False) + + async def run[T](self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T: + loop = asyncio.get_running_loop() + future = loop.run_in_executor( + self._executor, functools.partial(fn, *args, **kwargs) + ) + try: + return await asyncio.wait_for(future, timeout=self._timeout) + except TimeoutError as e: + raise TimeoutError(f"Operation timed out after {self._timeout}s") from e + + def shutdown(self, wait: bool = True) -> None: + self._executor.shutdown(wait=wait) diff --git a/vibe/core/utils/display.py b/vibe/core/utils/display.py new file mode 100644 index 0000000..52744a9 --- /dev/null +++ b/vibe/core/utils/display.py @@ -0,0 +1,13 @@ +from __future__ import annotations + + +def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> str: + if old_tokens is None or new_tokens is None: + return "Compaction complete" + + reduction = old_tokens - new_tokens + reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0 + return ( + f"Compaction complete: {old_tokens:,} → " + f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)" + ) diff --git a/vibe/core/utils/http.py b/vibe/core/utils/http.py new file mode 100644 index 0000000..a580103 --- /dev/null +++ b/vibe/core/utils/http.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import re + +from vibe import __version__ +from vibe.core.types import Backend + + +def get_user_agent(backend: Backend | None) -> str: + user_agent = f"Mistral-Vibe/{__version__}" + if backend == Backend.MISTRAL: + mistral_sdk_prefix = "mistral-client-python/" + user_agent = f"{mistral_sdk_prefix}{user_agent}" + return user_agent + + +def get_server_url_from_api_base(api_base: str) -> str | None: + match = re.match(r"(https?://[^/]+)(/v.*)", api_base) + return match.group(1) if match else None diff --git a/vibe/core/utils/io.py b/vibe/core/utils/io.py new file mode 100644 index 0000000..2b59a18 --- /dev/null +++ b/vibe/core/utils/io.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from pathlib import Path + +import anyio + + +def read_safe(path: Path, *, raise_on_error: bool = False) -> str: + """Read a text file trying UTF-8 first, falling back to OS-default encoding. + + On fallback, undecodable bytes are replaced with U+FFFD (REPLACEMENT CHARACTER). + When raise_on_error is True, decode errors propagate. + """ + try: + return path.read_text(encoding="utf-8") + except (UnicodeDecodeError, ValueError): + if raise_on_error: + return path.read_text() + return path.read_text(errors="replace") + + +async def read_safe_async(path: Path, *, raise_on_error: bool = False) -> str: + apath = anyio.Path(path) + try: + return await apath.read_text(encoding="utf-8") + except (UnicodeDecodeError, ValueError): + if raise_on_error: + return await apath.read_text() + return await apath.read_text(errors="replace") diff --git a/vibe/core/utils/matching.py b/vibe/core/utils/matching.py new file mode 100644 index 0000000..c52dbfd --- /dev/null +++ b/vibe/core/utils/matching.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from fnmatch import fnmatch +import functools +import re + + +@functools.lru_cache(maxsize=256) +def _compile_icase(expr: str) -> re.Pattern[str] | None: + try: + return re.compile(expr, re.IGNORECASE) + except re.error: + return None + + +def name_matches(name: str, patterns: list[str]) -> bool: + """Check if a name matches any of the provided patterns. + + Supports two forms (case-insensitive): + - Glob wildcards using fnmatch (e.g., 'serena_*') + - Regex when prefixed with 're:' (e.g., 're:serena.*') + """ + n = name.lower() + for raw in patterns: + if not (p := (raw or "").strip()): + continue + + if p.startswith("re:"): + rx = _compile_icase(p.removeprefix("re:")) + if rx is not None and rx.fullmatch(name) is not None: + return True + elif fnmatch(n, p.lower()): + return True + + return False diff --git a/vibe/core/utils/paths.py b/vibe/core/utils/paths.py new file mode 100644 index 0000000..5cf9d5a --- /dev/null +++ b/vibe/core/utils/paths.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path + + +def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]: + """Check if the current directory is a dangerous folder that would cause + issues if we were to run the tool there. + + Args: + path: Path to check (defaults to current directory) + + Returns: + tuple[bool, str]: (is_dangerous, reason) where reason explains why it's dangerous + """ + path = Path(path).resolve() + + home_dir = Path.home() + + dangerous_paths = { + home_dir: "home directory", + home_dir / "Documents": "Documents folder", + home_dir / "Desktop": "Desktop folder", + home_dir / "Downloads": "Downloads folder", + home_dir / "Pictures": "Pictures folder", + home_dir / "Movies": "Movies folder", + home_dir / "Music": "Music folder", + home_dir / "Library": "Library folder", + Path("/Applications"): "Applications folder", + Path("/System"): "System folder", + Path("/Library"): "System Library folder", + Path("/usr"): "System usr folder", + Path("/private"): "System private folder", + } + + for dangerous_path, description in dangerous_paths.items(): + try: + if path == dangerous_path: + return True, f"You are in the {description}" + except (OSError, ValueError): + continue + return False, "" diff --git a/vibe/core/utils/platform.py b/vibe/core/utils/platform.py new file mode 100644 index 0000000..10c3780 --- /dev/null +++ b/vibe/core/utils/platform.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +import sys + + +def is_windows() -> bool: + return sys.platform == "win32" diff --git a/vibe/core/utils/retry.py b/vibe/core/utils/retry.py new file mode 100644 index 0000000..afc1c6a --- /dev/null +++ b/vibe/core/utils/retry.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator, Awaitable, Callable +import functools + +import httpx + + +def _is_retryable_http_error(e: Exception) -> bool: + if isinstance(e, httpx.HTTPStatusError): + return e.response.status_code in {408, 409, 425, 429, 500, 502, 503, 504} + return False + + +def async_retry[T, **P]( + tries: int = 3, + delay_seconds: float = 0.5, + backoff_factor: float = 2.0, + is_retryable: Callable[[Exception], bool] = _is_retryable_http_error, +) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]: + """Args: + tries: Number of retry attempts + delay_seconds: Initial delay between retries in seconds + backoff_factor: Multiplier for delay on each retry + is_retryable: Function to determine if an exception should trigger a retry + (defaults to checking for retryable HTTP errors from both urllib and httpx) + + Returns: + Decorated function with retry logic + """ + + def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]: + @functools.wraps(func) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + last_exc = None + for attempt in range(tries): + try: + return await func(*args, **kwargs) + except Exception as e: + last_exc = e + if attempt < tries - 1 and is_retryable(e): + current_delay = (delay_seconds * (backoff_factor**attempt)) + ( + 0.05 * attempt + ) + await asyncio.sleep(current_delay) + continue + raise e + raise RuntimeError( + f"Retries exhausted. Last error: {last_exc}" + ) from last_exc + + return wrapper + + return decorator + + +def async_generator_retry[T, **P]( + tries: int = 3, + delay_seconds: float = 0.5, + backoff_factor: float = 2.0, + is_retryable: Callable[[Exception], bool] = _is_retryable_http_error, +) -> Callable[[Callable[P, AsyncGenerator[T]]], Callable[P, AsyncGenerator[T]]]: + """Retry decorator for async generators. + + Args: + tries: Number of retry attempts + delay_seconds: Initial delay between retries in seconds + backoff_factor: Multiplier for delay on each retry + is_retryable: Function to determine if an exception should trigger a retry + (defaults to checking for retryable HTTP errors from both urllib and httpx) + + Returns: + Decorated async generator function with retry logic + """ + + def decorator( + func: Callable[P, AsyncGenerator[T]], + ) -> Callable[P, AsyncGenerator[T]]: + @functools.wraps(func) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> AsyncGenerator[T]: + last_exc = None + for attempt in range(tries): + try: + async for item in func(*args, **kwargs): + yield item + return + except Exception as e: + last_exc = e + if attempt < tries - 1 and is_retryable(e): + current_delay = (delay_seconds * (backoff_factor**attempt)) + ( + 0.05 * attempt + ) + await asyncio.sleep(current_delay) + continue + raise e + raise RuntimeError( + f"Retries exhausted. Last error: {last_exc}" + ) from last_exc + + return wrapper + + return decorator diff --git a/vibe/core/slug.py b/vibe/core/utils/slug.py similarity index 100% rename from vibe/core/slug.py rename to vibe/core/utils/slug.py diff --git a/vibe/core/utils/tags.py b/vibe/core/utils/tags.py new file mode 100644 index 0000000..1fe512b --- /dev/null +++ b/vibe/core/utils/tags.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from enum import Enum, auto +import re + +from vibe.core.types import BaseEvent, ToolResultEvent + +CANCELLATION_TAG = "user_cancellation" +TOOL_ERROR_TAG = "tool_error" +VIBE_STOP_EVENT_TAG = "vibe_stop_event" +VIBE_WARNING_TAG = "vibe_warning" + +KNOWN_TAGS = [CANCELLATION_TAG, TOOL_ERROR_TAG, VIBE_STOP_EVENT_TAG, VIBE_WARNING_TAG] + + +class TaggedText: + _TAG_PATTERN = re.compile( + rf"<({'|'.join(re.escape(tag) for tag in KNOWN_TAGS)})>(.*?)", + flags=re.DOTALL, + ) + + def __init__(self, message: str, tag: str = "") -> None: + self.message = message + self.tag = tag + + def __str__(self) -> str: + if not self.tag: + return self.message + return f"<{self.tag}>{self.message}" + + @staticmethod + def from_string(text: str) -> TaggedText: + found_tag = "" + result = text + + def replace_tag(match: re.Match[str]) -> str: + nonlocal found_tag + tag_name = match.group(1) + content = match.group(2) + if not found_tag: + found_tag = tag_name + return content + + result = TaggedText._TAG_PATTERN.sub(replace_tag, text) + + if found_tag: + return TaggedText(result, found_tag) + + return TaggedText(text, "") + + +class CancellationReason(Enum): + OPERATION_CANCELLED = auto() + TOOL_INTERRUPTED = auto() + TOOL_NO_RESPONSE = auto() + TOOL_SKIPPED = auto() + + +def get_user_cancellation_message( + cancellation_reason: CancellationReason, tool_name: str | None = None +) -> TaggedText: + match cancellation_reason: + case CancellationReason.OPERATION_CANCELLED: + return TaggedText("User cancelled the operation.", CANCELLATION_TAG) + case CancellationReason.TOOL_INTERRUPTED: + return TaggedText("Tool execution interrupted by user.", CANCELLATION_TAG) + case CancellationReason.TOOL_NO_RESPONSE: + return TaggedText( + "Tool execution interrupted - no response available", CANCELLATION_TAG + ) + case CancellationReason.TOOL_SKIPPED: + return TaggedText( + tool_name or "Tool execution skipped by user.", CANCELLATION_TAG + ) + + +def is_user_cancellation_event(event: BaseEvent) -> bool: + if not isinstance(event, ToolResultEvent): + return False + return event.cancelled diff --git a/vibe/core/utils/time.py b/vibe/core/utils/time.py new file mode 100644 index 0000000..b229def --- /dev/null +++ b/vibe/core/utils/time.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from datetime import UTC, datetime + + +def utc_now() -> datetime: + return datetime.now(UTC) diff --git a/vibe/setup/onboarding/screens/api_key.py b/vibe/setup/onboarding/screens/api_key.py index 98003ab..ae18e2a 100644 --- a/vibe/setup/onboarding/screens/api_key.py +++ b/vibe/setup/onboarding/screens/api_key.py @@ -13,9 +13,10 @@ 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 Backend, VibeConfig +from vibe.core.config import VibeConfig from vibe.core.paths import GLOBAL_ENV_FILE from vibe.core.telemetry.send import TelemetryClient +from vibe.core.types import Backend from vibe.setup.onboarding.base import OnboardingScreen PROVIDER_HELP = { diff --git a/vibe/whats_new.md b/vibe/whats_new.md index c9498de..3157be0 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -1,4 +1,4 @@ -# What's new in v2.5.0 -- **Lean mode**: Setup a dedicated theorem proving agent powered by leanstral with /leanstall -- **Parallel tool execution**: Parallel tool calls are sped up by being ran at the same time -- **Voice mode**: Real-time transcription support with /voice +# What's new in v2.6.0 +- **Text-to-speech**: Added TTS functionality +- **Standalone resume**: New --resume command for session picker +- **Fine-grained permissions**: Improved permissions granularity and persistence