From 6bedf271ce120f028eb990e3ca6bd0d1a8685590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Drouin?= Date: Fri, 19 Jun 2026 11:01:24 +0200 Subject: [PATCH] v2.17.0 (#822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Clément Sirieix Co-authored-by: Guillaume LE GOFF Co-authored-by: Hdandria Co-authored-by: Ivana Dunisijevic Co-authored-by: Jean Burellier Co-authored-by: Mathias Gesbert Co-authored-by: Mert Unsal Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Pierre Rossinès Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com> Co-authored-by: Mistral Vibe --- CHANGELOG.md | 24 + README.md | 12 +- distribution/zed/extension.toml | 12 +- pyproject.toml | 16 +- tests/acp/test_acp.py | 1 + tests/acp/test_auth_status.py | 86 +- tests/acp/test_compact_session_updates.py | 1 + tests/acp/test_image_blocks.py | 113 ++ tests/acp/test_initialize.py | 8 +- tests/acp/test_load_session.py | 139 +- tests/acp/test_new_session.py | 246 ++- tests/acp/test_set_model.py | 1 + tests/acp/test_tool_call_session_update.py | 29 + tests/acp/test_tool_field_meta.py | 87 +- tests/acp/test_user_display_content.py | 144 ++ tests/acp/test_utils.py | 141 ++ tests/acp/test_workspace_trust.py | 140 ++ .../nuage => agent_loop/e2e}/__init__.py | 0 tests/agent_loop/e2e/conftest.py | 181 +++ tests/agent_loop/e2e/test_e2e_agent_loop.py | 111 ++ tests/agent_loop/e2e/test_e2e_bash.py | 170 +++ tests/agent_loop/e2e/test_e2e_connectors.py | 136 ++ .../agent_loop/e2e/test_e2e_fork_and_plan.py | 144 ++ .../agent_loop/e2e/test_e2e_provider_apis.py | 178 +++ tests/agent_loop/e2e/test_e2e_teleport.py | 224 +++ tests/agent_loop/e2e/test_e2e_tools.py | 93 ++ .../test_agent_auto_compact.py | 0 .../{ => agent_loop}/test_agent_auto_title.py | 0 tests/{ => agent_loop}/test_agent_backend.py | 0 .../test_agent_loop_images.py | 6 +- .../test_agent_loop_refresh_system_prompt.py | 0 .../test_agent_observer_streaming.py | 0 .../test_agent_override_resolve_permission.py | 0 tests/{ => agent_loop}/test_agent_stats.py | 0 .../{ => agent_loop}/test_agent_tool_call.py | 0 tests/{ => agent_loop}/test_agents.py | 0 .../test_approve_always_permanent.py | 0 tests/{ => agent_loop}/test_deferred_init.py | 18 + .../test_path_completion_controller.py | 4 +- .../test_slash_command_controller.py | 4 +- .../test_ui_chat_autocompletion.py | 43 +- tests/backend/data/anthropic.py | 132 ++ tests/backend/data/mistral.py | 34 + tests/backend/data/openai_responses.py | 140 +- tests/backend/test_anthropic_adapter.py | 74 +- tests/backend/test_backend.py | 90 +- tests/backend/test_image_encoding_cache.py | 30 +- tests/backend/test_image_mapping.py | 6 +- .../backend/test_openai_responses_adapter.py | 14 +- .../cli/plan_offer/test_decide_plan_offer.py | 21 + tests/cli/test_cache.py | 73 - tests/cli/test_commands.py | 8 + tests/cli/test_feedback_bar_manager.py | 56 +- tests/cli/test_help.py | 32 +- tests/cli/test_programmatic_setup.py | 150 +- tests/cli/test_rename_command.py | 34 +- tests/cli/test_session_delete.py | 56 +- tests/cli/test_startup_update_prompt.py | 87 +- tests/cli/test_ui_teleport_availability.py | 45 +- tests/cli/textual_ui/test_completion_popup.py | 12 - tests/cli/textual_ui/test_diff_rendering.py | 31 + tests/cli/textual_ui/test_message_queue_ui.py | 15 +- .../cli/textual_ui/test_quit_confirmation.py | 71 +- .../textual_ui/test_remote_session_manager.py | 286 ---- tests/cli/textual_ui/test_session_picker.py | 137 +- .../test_user_message_attachments.py | 6 +- tests/conftest.py | 41 + tests/constants.py | 14 + tests/core/nuage/test_remote_events_source.py | 301 ---- tests/core/nuage/test_workflows_client.py | 248 ---- tests/core/session/test_image_snapshot.py | 71 +- tests/core/test_agent_loop_refresh_config.py | 47 + tests/core/test_backend_error.py | 3 +- tests/core/test_build_attachment_counts.py | 6 +- tests/core/test_cache_store.py | 109 ++ tests/core/test_config_builder.py | 3 + tests/core/test_config_layer.py | 206 ++- tests/core/test_config_load_dotenv.py | 22 +- tests/core/test_config_mcp_auth.py | 35 +- tests/core/test_config_orchestrator.py | 3 + tests/core/test_config_otel.py | 15 +- tests/core/test_config_patch_ops.py | 252 ++-- tests/core/test_fingerprint.py | 32 +- tests/core/test_image_attachment.py | 58 + tests/core/test_llm_message_merge.py | 45 +- tests/core/test_mcp_oauth.py | 57 + tests/core/test_message_list.py | 56 + tests/core/test_overrides_layer.py | 8 - tests/core/test_project_config_layer.py | 8 - tests/core/test_remote_agent_loop.py | 937 ------------ tests/core/test_resolve_api_key.py | 138 ++ tests/core/test_telemetry_send.py | 10 - tests/core/test_teleport_nuage.py | 3 +- tests/core/test_teleport_service.py | 11 +- tests/core/test_text.py | 25 +- tests/core/test_user_config_layer.py | 264 +++- tests/core/test_user_display_content.py | 131 ++ tests/core/test_vibe_config_schema.py | 2 + tests/core/tools/builtins/test_edit.py | 60 +- .../agent_loop_characterization/support.py | 178 +++ .../test_resume.py | 224 +++ .../test_subagents.py | 87 ++ .../test_tool_execution.py | 219 +++ .../test_tool_permissions.py | 214 +++ .../test_user_interaction.py | 100 ++ tests/e2e/mock_server.py | 123 +- tests/e2e/test_mock_server.py | 73 + tests/onboarding/test_ui_onboarding.py | 13 + tests/session/test_resume_sessions.py | 272 +--- tests/setup/auth/test_api_key_persistence.py | 148 ++ tests/setup/auth/test_auth_state.py | 73 +- tests/setup/test_update_prompt_dialog.py | 28 + tests/setup/test_update_prompt_theme.py | 79 + ...izontal_scrolling_for_long_code_blocks.svg | 6 +- .../test_snapshot_data_retention.svg | 6 +- ...napshot_edit_approval_diff_replace_all.svg | 190 +++ ...apshot_right_padding_assistant_message.svg | 201 +++ .../test_snapshot_right_padding_reasoning.svg | 202 +++ .../test_snapshot_session_picker_header.svg | 9 +- ...ort_command_hidden_for_non_pro_account.svg | 86 +- ...leport_command_visible_for_pro_account.svg | 72 +- tests/snapshots/test_ui_snapshot_edit_diff.py | 42 + .../test_ui_snapshot_right_padding.py | 79 + .../test_ui_snapshot_session_picker.py | 11 +- tests/stubs/fake_mcp_registry.py | 2 + tests/test_mcp_auth_notices.py | 79 + tests/test_reasoning_content.py | 60 +- tests/tools/test_connectors.py | 3 +- tests/tools/test_mcp.py | 353 +++++ tests/tools/test_mcp_registry_oauth.py | 323 ++++ tests/tools/test_task.py | 43 +- tests/update_notifier/test_update_use_case.py | 28 + uv.lock | 134 +- vibe/__init__.py | 2 +- vibe/acp/acp_agent_loop.py | 425 +++++- vibe/acp/commands/registry.py | 6 + vibe/acp/exceptions.py | 21 + vibe/acp/image_blocks.py | 62 + vibe/acp/tools/builtins/read.py | 61 +- vibe/acp/tools/session_update.py | 14 +- vibe/acp/user_display_content.py | 14 + vibe/acp/utils.py | 52 +- vibe/cli/autocompletion/base.py | 2 +- vibe/cli/autocompletion/slash_command.py | 2 +- vibe/cli/cache.py | 32 - vibe/cli/cli.py | 130 +- vibe/cli/commands.py | 3 +- vibe/cli/entrypoint.py | 22 +- vibe/cli/plan_offer/decide_plan_offer.py | 4 +- vibe/cli/textual_ui/app.py | 484 +++--- vibe/cli/textual_ui/app.tcss | 58 +- vibe/cli/textual_ui/handlers/event_handler.py | 5 - vibe/cli/textual_ui/message_queue.py | 28 +- vibe/cli/textual_ui/remote/__init__.py | 8 - .../remote/remote_session_manager.py | 211 --- .../widgets/chat_input/completion_popup.py | 68 +- .../widgets/chat_input/container.py | 54 +- .../widgets/chat_input/text_area.py | 17 +- vibe/cli/textual_ui/widgets/diff_rendering.py | 34 +- .../widgets/feedback_bar_manager.py | 5 +- vibe/cli/textual_ui/widgets/messages.py | 23 +- vibe/cli/textual_ui/widgets/session_picker.py | 54 +- vibe/cli/textual_ui/widgets/tool_widgets.py | 12 +- vibe/cli/turn_summary/utils.py | 14 +- .../filesystem_update_cache_repository.py | 18 +- vibe/cli/update_notifier/update.py | 4 +- .../adapters/filesystem_repository.py | 13 +- vibe/core/agent_loop.py | 83 +- vibe/core/auth/mcp_oauth.py | 47 +- vibe/core/cache_store.py | 69 + vibe/core/config/__init__.py | 22 +- vibe/core/config/_settings.py | 66 +- vibe/core/config/fingerprint.py | 6 +- vibe/core/config/layer.py | 79 +- vibe/core/config/layers/environment.py | 6 +- vibe/core/config/layers/overrides.py | 14 +- vibe/core/config/layers/project.py | 18 +- vibe/core/config/layers/user.py | 46 +- vibe/core/config/patch.py | 127 +- vibe/core/config/vibe_schema.py | 15 +- vibe/core/feedback.py | 17 +- vibe/core/llm/backend/_image.py | 16 +- vibe/core/llm/backend/anthropic.py | 123 -- vibe/core/llm/backend/factory.py | 23 + vibe/core/llm/backend/generic.py | 15 +- vibe/core/llm/backend/mistral.py | 19 +- vibe/core/nuage/__init__.py | 0 vibe/core/nuage/agent_models.py | 26 - vibe/core/nuage/client.py | 209 --- vibe/core/nuage/events.py | 227 --- vibe/core/nuage/exceptions.py | 46 - vibe/core/nuage/remote_events_source.py | 207 --- .../nuage/remote_workflow_event_models.py | 137 -- .../nuage/remote_workflow_event_translator.py | 1313 ----------------- vibe/core/nuage/streaming.py | 32 - vibe/core/nuage/workflow.py | 44 - vibe/core/session/image_snapshot.py | 78 +- vibe/core/session/resume_sessions.py | 178 +-- vibe/core/skills/builtins/vibe.py | 45 +- vibe/core/system_prompt.py | 7 +- vibe/core/telemetry/send.py | 10 +- vibe/core/telemetry/types.py | 2 +- vibe/core/tools/base.py | 2 + vibe/core/tools/builtins/edit.py | 15 +- vibe/core/tools/builtins/read.py | 4 + vibe/core/tools/builtins/task.py | 5 +- vibe/core/tools/builtins/websearch.py | 11 +- vibe/core/tools/manager.py | 1 + vibe/core/tools/mcp/__init__.py | 5 +- vibe/core/tools/mcp/pool.py | 262 ++++ vibe/core/tools/mcp/registry.py | 223 ++- vibe/core/tools/mcp/tools.py | 136 +- .../transcribe/mistral_transcribe_client.py | 9 +- vibe/core/trusted_folders.py | 34 +- vibe/core/tts/mistral_tts_client.py | 5 +- vibe/core/types.py | 85 +- vibe/core/utils/text.py | 17 +- vibe/setup/auth/api_key_persistence.py | 37 +- vibe/setup/auth/auth_state.py | 62 +- vibe/setup/update_prompt/__init__.py | 9 +- vibe/setup/update_prompt/theme.py | 42 + .../update_prompt/update_prompt_dialog.py | 40 +- vibe/whats_new.md | 9 +- 223 files changed, 10533 insertions(+), 6947 deletions(-) create mode 100644 tests/acp/test_image_blocks.py create mode 100644 tests/acp/test_user_display_content.py create mode 100644 tests/acp/test_workspace_trust.py rename tests/{core/nuage => agent_loop/e2e}/__init__.py (100%) create mode 100644 tests/agent_loop/e2e/conftest.py create mode 100644 tests/agent_loop/e2e/test_e2e_agent_loop.py create mode 100644 tests/agent_loop/e2e/test_e2e_bash.py create mode 100644 tests/agent_loop/e2e/test_e2e_connectors.py create mode 100644 tests/agent_loop/e2e/test_e2e_fork_and_plan.py create mode 100644 tests/agent_loop/e2e/test_e2e_provider_apis.py create mode 100644 tests/agent_loop/e2e/test_e2e_teleport.py create mode 100644 tests/agent_loop/e2e/test_e2e_tools.py rename tests/{ => agent_loop}/test_agent_auto_compact.py (100%) rename tests/{ => agent_loop}/test_agent_auto_title.py (100%) rename tests/{ => agent_loop}/test_agent_backend.py (100%) rename tests/{ => agent_loop}/test_agent_loop_images.py (96%) rename tests/{ => agent_loop}/test_agent_loop_refresh_system_prompt.py (100%) rename tests/{ => agent_loop}/test_agent_observer_streaming.py (100%) rename tests/{ => agent_loop}/test_agent_override_resolve_permission.py (100%) rename tests/{ => agent_loop}/test_agent_stats.py (100%) rename tests/{ => agent_loop}/test_agent_tool_call.py (100%) rename tests/{ => agent_loop}/test_agents.py (100%) rename tests/{ => agent_loop}/test_approve_always_permanent.py (100%) rename tests/{ => agent_loop}/test_deferred_init.py (96%) create mode 100644 tests/backend/data/anthropic.py delete mode 100644 tests/cli/test_cache.py delete mode 100644 tests/cli/textual_ui/test_completion_popup.py delete mode 100644 tests/cli/textual_ui/test_remote_session_manager.py create mode 100644 tests/constants.py delete mode 100644 tests/core/nuage/test_remote_events_source.py delete mode 100644 tests/core/nuage/test_workflows_client.py create mode 100644 tests/core/test_agent_loop_refresh_config.py create mode 100644 tests/core/test_cache_store.py create mode 100644 tests/core/test_image_attachment.py create mode 100644 tests/core/test_message_list.py delete mode 100644 tests/core/test_remote_agent_loop.py create mode 100644 tests/core/test_resolve_api_key.py create mode 100644 tests/core/test_user_display_content.py create mode 100644 tests/e2e/agent_loop_characterization/support.py create mode 100644 tests/e2e/agent_loop_characterization/test_resume.py create mode 100644 tests/e2e/agent_loop_characterization/test_subagents.py create mode 100644 tests/e2e/agent_loop_characterization/test_tool_execution.py create mode 100644 tests/e2e/agent_loop_characterization/test_tool_permissions.py create mode 100644 tests/e2e/agent_loop_characterization/test_user_interaction.py create mode 100644 tests/e2e/test_mock_server.py create mode 100644 tests/setup/auth/test_api_key_persistence.py create mode 100644 tests/setup/test_update_prompt_theme.py create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_replace_all.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_right_padding/test_snapshot_right_padding_assistant_message.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_right_padding/test_snapshot_right_padding_reasoning.svg create mode 100644 tests/snapshots/test_ui_snapshot_right_padding.py create mode 100644 tests/test_mcp_auth_notices.py create mode 100644 tests/tools/test_mcp_registry_oauth.py create mode 100644 vibe/acp/image_blocks.py create mode 100644 vibe/acp/user_display_content.py delete mode 100644 vibe/cli/cache.py delete mode 100644 vibe/cli/textual_ui/remote/__init__.py delete mode 100644 vibe/cli/textual_ui/remote/remote_session_manager.py create mode 100644 vibe/core/cache_store.py delete mode 100644 vibe/core/nuage/__init__.py delete mode 100644 vibe/core/nuage/agent_models.py delete mode 100644 vibe/core/nuage/client.py delete mode 100644 vibe/core/nuage/events.py delete mode 100644 vibe/core/nuage/exceptions.py delete mode 100644 vibe/core/nuage/remote_events_source.py delete mode 100644 vibe/core/nuage/remote_workflow_event_models.py delete mode 100644 vibe/core/nuage/remote_workflow_event_translator.py delete mode 100644 vibe/core/nuage/streaming.py delete mode 100644 vibe/core/nuage/workflow.py create mode 100644 vibe/core/tools/mcp/pool.py create mode 100644 vibe/setup/update_prompt/theme.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a01d3a6..6449832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ 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.17.0] - 2026-06-19 + +### Added + +- `/mcp login`, `/mcp logout`, and `/mcp status` commands to authenticate OAuth-backed MCP servers from the TUI +- `vibe --check-upgrade` to force an immediate update check and exit +- `--yolo` as an alias for `--auto-approve` +- ACP now accepts inline image content blocks + +### Changed + +- API keys are now stored in the OS keyring instead of plain text +- Edit diff view now shows all replaced occurrences instead of just the first +- Completion popup now uses a two-column layout +- Chat messages now have right padding so text no longer collapses into the scrollbar +- Faster CLI shutdown by deferring resource cleanup on exit + +### Fixed + +- Skill autocomplete popup now dismisses after Tab completion +- Stdio MCP connections now persist across tool calls +- Retryable 5xx responses from the Mistral backend are now retried instead of failing + + ## [2.16.1] - 2026-06-16 ### Added diff --git a/README.md b/README.md index 9e9052b..5ce0825 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,8 @@ custom agent file in `~/.vibe/agents/` or the project's `.vibe/agents/` directory. Subagents such as `explore` are not accepted. > Note: `default_agent` applies in both interactive and programmatic -> (`-p` / `--prompt`) sessions. Pass `--auto-approve` when a run should -> approve all tool calls without prompting. +> (`-p` / `--prompt`) sessions. Pass `--auto-approve` or `--yolo` when +> a run should approve all tool calls without prompting. ### Subagents and Task Delegation @@ -262,8 +262,8 @@ vibe --prompt "Refactor the main function in cli/main.py to be more modular." ``` By default, it uses your configured `default_agent` (`default` unless changed). -To approve all tool calls without prompting, pass `--auto-approve` (also -available for interactive sessions): +To approve all tool calls without prompting, pass `--auto-approve` or `--yolo` +(also available for interactive sessions): ```bash vibe --prompt "Refactor the main function in cli/main.py to be more modular." --auto-approve @@ -277,7 +277,7 @@ When using `--prompt`, you can specify additional options: - **`--max-price DOLLARS`**: Set a maximum cost limit in dollars. The session will be interrupted if the cost exceeds this limit. - **`--max-tokens N`**: Set a maximum cumulative LLM token budget for the session, counting both prompt and completion tokens. The session will be interrupted if usage exceeds this limit. - **`--agent NAME`**: Select the agent profile for this run. -- **`--auto-approve`**: Shortcut for `--agent auto-approve`. Approves all tool calls without prompting, including in interactive sessions. +- **`--auto-approve`, `--yolo`**: Shortcut for `--agent auto-approve`. Approves all tool calls without prompting, including in interactive sessions. - **`--enabled-tools TOOL`**: Enable specific tools. In programmatic mode, this disables all other tools. Can be specified multiple times. Supports exact names, glob patterns (e.g., `bash*`), or regex with `re:` prefix (e.g., `re:^serena_.*$`). - **`--output FORMAT`**: Set the output format. Options: - `text` (default): Human-readable text output @@ -730,6 +730,8 @@ Each path is implicitly trusted (no trust prompt) and contributes its `AGENTS.md Vibe checks PyPI at most once per day during a session. When a newer version is found, the next launch shows an update prompt before opening the chat, offering to either update immediately (via `uv tool upgrade mistral-vibe` or `brew upgrade mistral-vibe`) or continue with the current version. +Run `vibe --check-upgrade` to check PyPI immediately, prompt to install a newer version if one exists, and exit. + To disable the daily check entirely, add this to your `config.toml`: ```toml diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index b4427cf..9082bd7 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.16.1" +version = "2.17.0" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,21 +11,21 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.1/vibe-acp-darwin-aarch64-2.16.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-darwin-aarch64-2.17.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.1/vibe-acp-darwin-x86_64-2.16.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-darwin-x86_64-2.17.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.1/vibe-acp-linux-aarch64-2.16.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-linux-aarch64-2.17.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.1/vibe-acp-linux-x86_64-2.16.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-linux-x86_64-2.17.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.16.1/vibe-acp-windows-x86_64-2.16.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.17.0/vibe-acp-windows-x86_64-2.17.0.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index 1209189..c6a2088 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.16.1" +version = "2.17.0" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" @@ -38,7 +38,7 @@ dependencies = [ "charset-normalizer==3.4.7", "click==8.3.3 ; sys_platform != 'emscripten'", "colorama==0.4.6 ; sys_platform == 'win32'", - "cryptography==47.0.0", + "cryptography==48.0.1", "eval-type-backport==0.3.1", "gitdb==4.0.12", "gitpython==3.1.47", @@ -65,10 +65,10 @@ dependencies = [ "linkify-it-py==2.1.0", "markdown-it-py==4.0.0", "markdownify==1.2.2", - "mcp==1.27.1", + "mcp==1.27.2", "mdit-py-plugins==0.5.0", "mdurl==0.1.2", - "mistralai==2.4.4", + "mistralai==2.4.9", "more-itertools==11.0.2", "opentelemetry-api==1.39.1", "opentelemetry-exporter-otlp-proto-common==1.39.1", @@ -88,11 +88,11 @@ dependencies = [ "pydantic-core==2.46.3", "pydantic-settings==2.14.0", "pygments==2.20.0", - "pyjwt==2.12.1", + "pyjwt==2.13.0", "pyperclip==1.11.0", "python-dateutil==2.9.0.post0", "python-dotenv==1.2.2", - "python-multipart==0.0.27", + "python-multipart==0.0.32", "pywin32==311 ; sys_platform == 'win32'", "pywin32-ctypes==0.2.3 ; sys_platform == 'win32'", "pyyaml==6.0.3", @@ -116,7 +116,7 @@ dependencies = [ "typing-extensions==4.15.0", "typing-inspection==0.4.2", "uc-micro-py==2.0.0", - "urllib3==2.6.3", + "urllib3==2.7.0", "uvicorn==0.46.0 ; sys_platform != 'emscripten'", "watchfiles==1.1.1", "websockets==16.0", @@ -147,7 +147,7 @@ vibe-acp = "vibe.acp.entrypoint:main" [tool.uv] -exclude-newer = "7 days" +exclude-newer = "2 days" package = true required-version = ">=0.8.0" diff --git a/tests/acp/test_acp.py b/tests/acp/test_acp.py index 9a13d69..e1d4ca9 100644 --- a/tests/acp/test_acp.py +++ b/tests/acp/test_acp.py @@ -553,6 +553,7 @@ class TestSessionUpdates: assert tool_call.params.update.session_update == "tool_call" assert tool_call.params.update.kind == "search" + assert tool_call.params.update.status == "pending" assert tool_call.params.update.title == "Grepping 'auth'" assert ( tool_call.params.update.raw_input diff --git a/tests/acp/test_auth_status.py b/tests/acp/test_auth_status.py index b0703c1..760d6bc 100644 --- a/tests/acp/test_auth_status.py +++ b/tests/acp/test_auth_status.py @@ -4,10 +4,12 @@ import os from pathlib import Path from dotenv import dotenv_values +import keyring +from keyring.errors import KeyringError import pytest from vibe.acp.acp_agent_loop import VibeAcpAgentLoop -from vibe.acp.exceptions import InvalidRequestError +from vibe.acp.exceptions import InternalError, InvalidRequestError from vibe.core.config import ( DEFAULT_MISTRAL_API_ENV_KEY, ProviderConfig, @@ -18,6 +20,15 @@ from vibe.setup.auth import AuthStateKind from vibe.setup.onboarding.context import OnboardingContext +@pytest.fixture(autouse=True) +def disable_keyring(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(keyring, "get_password", lambda service, username: None) + monkeypatch.setattr( + keyring, "set_password", lambda service, username, password: None + ) + monkeypatch.setattr(keyring, "delete_password", lambda service, username: None) + + def build_mistral_provider( *, api_key_env_var: str = DEFAULT_MISTRAL_API_ENV_KEY ) -> ProviderConfig: @@ -127,7 +138,7 @@ class TestACPAuthStatus: } @pytest.mark.asyncio - async def test_returns_combined_source_when_process_env_existed_before_dotenv( + async def test_returns_process_env_when_process_env_existed_before_dotenv( self, config_dir: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv(DEFAULT_MISTRAL_API_ENV_KEY, "process-key") @@ -138,7 +149,25 @@ class TestACPAuthStatus: assert response == { "authenticated": True, - "authState": AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV.value, + "authState": AuthStateKind.PROCESS_ENV.value, + "signOutAvailable": False, + } + + @pytest.mark.asyncio + async def test_returns_keyring_when_key_only_exists_in_keyring( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv(DEFAULT_MISTRAL_API_ENV_KEY, raising=False) + monkeypatch.setattr( + keyring, "get_password", lambda service, username: "keyring-key" + ) + acp_agent_loop = build_acp_agent_loop(build_mistral_provider()) + + response = await acp_agent_loop.ext_method("auth/status", {}) + + assert response == { + "authenticated": True, + "authState": AuthStateKind.OS_KEYRING.value, "signOutAvailable": True, } @@ -213,23 +242,58 @@ class TestACPAuthSignOut: assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key" @pytest.mark.asyncio - async def test_removes_dotenv_key_and_restores_process_env_key( + async def test_refuses_dotenv_key_when_process_env_key_exists( self, config_dir: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv(DEFAULT_MISTRAL_API_ENV_KEY, "process-key") write_env_file(config_dir, f"{DEFAULT_MISTRAL_API_ENV_KEY}=file-key\n") acp_agent_loop = build_acp_agent_loop(build_mistral_provider()) + with pytest.raises(InvalidRequestError, match=AuthStateKind.PROCESS_ENV.value): + await acp_agent_loop.ext_method("auth/signOut", {}) + + assert ( + dotenv_values(config_dir / ".env")[DEFAULT_MISTRAL_API_ENV_KEY] + == "file-key" + ) + assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key" + + @pytest.mark.asyncio + async def test_removes_keyring_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + deleted: list[str] = [] + monkeypatch.delenv(DEFAULT_MISTRAL_API_ENV_KEY, raising=False) + monkeypatch.setattr( + keyring, "get_password", lambda service, username: "keyring-key" + ) + monkeypatch.setattr( + keyring, + "delete_password", + lambda service, username: deleted.append(username), + ) + acp_agent_loop = build_acp_agent_loop(build_mistral_provider()) + response = await acp_agent_loop.ext_method("auth/signOut", {}) assert response == {} - assert DEFAULT_MISTRAL_API_ENV_KEY not in dotenv_values(config_dir / ".env") - assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key" - assert await acp_agent_loop.ext_method("auth/status", {}) == { - "authenticated": True, - "authState": AuthStateKind.PROCESS_ENV.value, - "signOutAvailable": False, - } + assert deleted == [DEFAULT_MISTRAL_API_ENV_KEY] + + @pytest.mark.asyncio + async def test_surfaces_internal_error_when_keyring_delete_fails( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv(DEFAULT_MISTRAL_API_ENV_KEY, raising=False) + monkeypatch.setattr( + keyring, "get_password", lambda service, username: "keyring-key" + ) + + def _failed(service: str, username: str) -> None: + raise KeyringError("delete failed") + + monkeypatch.setattr(keyring, "delete_password", _failed) + acp_agent_loop = build_acp_agent_loop(build_mistral_provider()) + + with pytest.raises(InternalError, match="Failed to sign out"): + await acp_agent_loop.ext_method("auth/signOut", {}) @pytest.mark.asyncio async def test_refuses_unsupported_provider_key( diff --git a/tests/acp/test_compact_session_updates.py b/tests/acp/test_compact_session_updates.py index 0c79a75..f659fb9 100644 --- a/tests/acp/test_compact_session_updates.py +++ b/tests/acp/test_compact_session_updates.py @@ -101,6 +101,7 @@ class TestCompactEventHandling: cwd=str(Path.cwd()), mcp_servers=[] ) session = acp_agent_loop.sessions[session_response.session_id] + await session.agent_loop.wait_until_ready() # Need >1 message so /compact does not early-return. session.agent_loop.messages.append(LLMMessage(role=Role.user, content="hello")) diff --git a/tests/acp/test_image_blocks.py b/tests/acp/test_image_blocks.py new file mode 100644 index 0000000..224c8b4 --- /dev/null +++ b/tests/acp/test_image_blocks.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import base64 +import hashlib +from pathlib import Path + +from acp.helpers import ImageContentBlock, TextContentBlock +import pytest + +from vibe.acp.exceptions import INVALID_IMAGE_ATTACHMENT, InvalidImageAttachmentError +from vibe.acp.image_blocks import extract_image_attachments +from vibe.core.types import ( + MAX_IMAGE_BYTES, + MAX_IMAGES_PER_MESSAGE, + FileImageSource, + InlineImageSource, +) + +PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 + + +def _image_block(uri: str | None = None) -> ImageContentBlock: + return ImageContentBlock( + type="image", + data=base64.b64encode(PNG_BYTES).decode("ascii"), + mime_type="image/png", + uri=uri, + ) + + +def test_inlines_bytes_when_session_dir_is_none() -> None: + [att] = extract_image_attachments([_image_block()], session_dir=None) + + assert isinstance(att.source, InlineImageSource) + assert att.source.data == base64.b64encode(PNG_BYTES).decode("ascii") + assert att.mime_type == "image/png" + + +def test_writes_attachment_file_when_session_dir_is_set(tmp_path: Path) -> None: + [att] = extract_image_attachments([_image_block()], session_dir=tmp_path) + + digest = hashlib.sha1(PNG_BYTES, usedforsecurity=False).hexdigest() + assert isinstance(att.source, FileImageSource) + assert att.source.path == (tmp_path / "attachments" / f"{digest}.png").resolve() + assert att.source.path.read_bytes() == PNG_BYTES + + +def test_derives_alias_from_uri_basename() -> None: + [att] = extract_image_attachments([_image_block(uri="cat.png")], session_dir=None) + + assert att.alias == "cat.png" + + +def test_falls_back_to_default_alias_without_uri() -> None: + [att] = extract_image_attachments([_image_block()], session_dir=None) + + assert att.alias == "pasted-image.png" + + +def test_ignores_non_image_blocks() -> None: + block = TextContentBlock(type="text", text="hello") + + assert extract_image_attachments([block], session_dir=None) == [] + + +def test_rejects_unsupported_mime() -> None: + block = ImageContentBlock( + type="image", + data=base64.b64encode(PNG_BYTES).decode("ascii"), + mime_type="image/tiff", + ) + + with pytest.raises(InvalidImageAttachmentError): + extract_image_attachments([block], session_dir=None) + + +def test_image_block_error_is_structured_acp_error() -> None: + block = ImageContentBlock( + type="image", + data=base64.b64encode(PNG_BYTES).decode("ascii"), + mime_type="image/tiff", + ) + + with pytest.raises(InvalidImageAttachmentError) as exc_info: + extract_image_attachments([block], session_dir=None) + + assert exc_info.value.code == INVALID_IMAGE_ATTACHMENT + assert exc_info.value.data == {"reason": "wrong_type"} + + +def test_rejects_invalid_base64() -> None: + block = ImageContentBlock(type="image", data="not base64!!!", mime_type="image/png") + + with pytest.raises(InvalidImageAttachmentError): + extract_image_attachments([block], session_dir=None) + + +def test_rejects_too_many_images() -> None: + blocks = [_image_block() for _ in range(MAX_IMAGES_PER_MESSAGE + 1)] + + with pytest.raises(InvalidImageAttachmentError): + extract_image_attachments(blocks, session_dir=None) + + +def test_rejects_oversized_image() -> None: + block = ImageContentBlock( + type="image", + data=base64.b64encode(b"x" * (MAX_IMAGE_BYTES + 1)).decode("ascii"), + mime_type="image/png", + ) + + with pytest.raises(InvalidImageAttachmentError): + extract_image_attachments([block], session_dir=None) diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index 9173ba9..2aa9c97 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -63,7 +63,7 @@ class TestACPInitialize: assert response.agent_capabilities == AgentCapabilities( load_session=True, prompt_capabilities=PromptCapabilities( - audio=False, embedded_context=True, image=False + audio=False, embedded_context=True, image=True ), session_capabilities=SessionCapabilities( close=SessionCloseCapabilities(), @@ -72,7 +72,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.16.1" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.0" ) assert response.auth_methods is not None @@ -163,7 +163,7 @@ class TestACPInitialize: assert response.agent_capabilities == AgentCapabilities( load_session=True, prompt_capabilities=PromptCapabilities( - audio=False, embedded_context=True, image=False + audio=False, embedded_context=True, image=True ), session_capabilities=SessionCapabilities( close=SessionCloseCapabilities(), @@ -172,7 +172,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.16.1" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.17.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 c90b8d0..a0d6726 100644 --- a/tests/acp/test_load_session.py +++ b/tests/acp/test_load_session.py @@ -1,13 +1,11 @@ from __future__ import annotations from pathlib import Path -from unittest.mock import AsyncMock from acp import RequestError from acp.schema import ( AgentMessageChunk, AgentThoughtChunk, - ClientCapabilities, ToolCallProgress, ToolCallStart, UserMessageChunk, @@ -17,7 +15,8 @@ import pytest from tests.conftest import build_test_vibe_config from tests.stubs.fake_backend import FakeBackend from tests.stubs.fake_client import FakeClient -from vibe.acp.acp_agent_loop import WORKSPACE_TRUST_CAPABILITY, VibeAcpAgentLoop +from vibe.acp.acp_agent_loop import VibeAcpAgentLoop +from vibe.acp.user_display_content import USER_DISPLAY_CONTENT_META_KEY from vibe.core.agent_loop import AgentLoop from vibe.core.agents.models import BuiltinAgentName from vibe.core.config import ModelConfig, SessionLoggingConfig @@ -126,35 +125,42 @@ class TestLoadSession: assert response.config_options[2].current_value == "off" @pytest.mark.asyncio - async def test_load_session_resolves_workspace_trust_before_loading_config( + async def test_load_session_returns_trust_details_without_loading_project_docs( self, acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], temp_session_dir: Path, create_test_session, tmp_working_directory: Path, - monkeypatch: pytest.MonkeyPatch, ) -> None: - acp_agent, client = acp_agent_with_session_config - acp_agent.client_capabilities = ClientCapabilities( - field_meta={WORKSPACE_TRUST_CAPABILITY: True} - ) - request_trust = AsyncMock(return_value={"decision": "trust_cwd"}) - monkeypatch.setattr(client, "ext_method", request_trust) + acp_agent, _client = acp_agent_with_session_config (tmp_working_directory / "AGENTS.md").write_text( "Loaded session project instructions", encoding="utf-8" ) session_id = "test-sess-trust" create_test_session(temp_session_dir, session_id, str(tmp_working_directory)) - await acp_agent.load_session( + response = await acp_agent.load_session( cwd=str(tmp_working_directory), mcp_servers=[], session_id=session_id ) - request_trust.assert_awaited_once() - assert trusted_folders_manager.is_trusted(tmp_working_directory) is True + assert response is not None + payload = response.model_dump(mode="json", by_alias=True) + assert payload.get("_meta") == { + "workspace_trust": { + "status": "untrusted", + "details": { + "cwd": str(tmp_working_directory.resolve()), + "repoRoot": None, + "ignoredFiles": ["AGENTS.md"], + "availableDecisions": ["trust_cwd", "trust_session", "decline"], + }, + } + } + assert trusted_folders_manager.is_trusted(tmp_working_directory) is None + await acp_agent.sessions[session_id].agent_loop.wait_until_ready() system_prompt = acp_agent.sessions[session_id].agent_loop.messages[0].content assert system_prompt is not None - assert "Loaded session project instructions" in system_prompt + assert "Loaded session project instructions" not in system_prompt @pytest.mark.asyncio async def test_load_session_registers_session_with_original_id( @@ -223,6 +229,51 @@ class TestLoadSession: ] assert len(user_updates) == 1 assert user_updates[0].update.content.text == "Hello world" + assert user_updates[0].update.field_meta is None + + @pytest.mark.asyncio + async def test_load_session_replays_user_display_content( + self, + acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], + temp_session_dir: Path, + create_test_session, + ) -> None: + acp_agent, client = acp_agent_with_session_config + + session_id = "replay-dsp-123456" + cwd = str(Path.cwd()) + user_display_content = { + "version": "1.0.0", + "host": "mistral-vscode", + "content": [ + {"type": "text", "text": "Look at "}, + { + "type": "workspace_mention", + "kind": "file", + "uri": "file:///repo/src/app.ts", + "name": "app.ts", + }, + ], + } + messages = [ + { + "role": "user", + "content": "Look at app.ts", + "user_display_content": user_display_content, + } + ] + create_test_session(temp_session_dir, session_id, cwd, messages=messages) + + await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id) + + user_updates = [ + u for u in client._session_updates if isinstance(u.update, UserMessageChunk) + ] + assert len(user_updates) == 1 + assert user_updates[0].update.content.text == "Look at app.ts" + assert user_updates[0].update.field_meta == { + USER_DISPLAY_CONTENT_META_KEY: user_display_content + } @pytest.mark.asyncio async def test_load_session_replays_assistant_messages( @@ -278,7 +329,12 @@ class TestLoadSession: } ], }, - {"role": "tool", "tool_call_id": "call_123", "content": "file contents"}, + { + "role": "tool", + "tool_call_id": "call_123", + "name": "read", + "content": "file contents", + }, ] create_test_session(temp_session_dir, session_id, cwd, messages=messages) @@ -288,15 +344,58 @@ class TestLoadSession: u for u in client._session_updates if isinstance(u.update, ToolCallStart) ] assert len(tool_call_starts) == 1 - assert tool_call_starts[0].update.title == "read" - assert tool_call_starts[0].update.tool_call_id == "call_123" + start = tool_call_starts[0].update + assert start.tool_call_id == "call_123" + assert start.kind == "read" + # The host drops created events without a status; replayed calls are + # historical, so they must carry one. + assert start.status == "completed" + assert start.field_meta is not None + assert start.field_meta["tool_name"] == "read" tool_results = [ u for u in client._session_updates if isinstance(u.update, ToolCallProgress) ] assert len(tool_results) == 1 - assert tool_results[0].update.tool_call_id == "call_123" - assert tool_results[0].update.status == "completed" + result = tool_results[0].update + assert result.tool_call_id == "call_123" + assert result.status == "completed" + # The host drops tool_call_update events without a kind. + assert result.kind == "read" + assert result.field_meta is not None + assert result.field_meta["tool_name"] == "read" + + @pytest.mark.asyncio + async def test_load_session_skips_result_whose_call_was_not_replayed( + self, + acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], + temp_session_dir: Path, + create_test_session, + ) -> None: + acp_agent, client = acp_agent_with_session_config + + session_id = "replay-orphan-12345" + cwd = str(Path.cwd()) + # A tool result whose call was not replayed (here no matching tool call + # at all, mirroring a hidden tool whose call replay returns None). + # Emitting it would orphan a tool_call_update with no preceding call. + messages = [ + {"role": "user", "content": "Do something"}, + { + "role": "tool", + "tool_call_id": "call_orphan", + "name": "read", + "content": "file contents", + }, + ] + create_test_session(temp_session_dir, session_id, cwd, messages=messages) + + await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id) + + tool_results = [ + u for u in client._session_updates if isinstance(u.update, ToolCallProgress) + ] + assert tool_results == [] @pytest.mark.asyncio async def test_load_session_replays_reasoning_content( diff --git a/tests/acp/test_new_session.py b/tests/acp/test_new_session.py index 2fb55d0..bfd7c36 100644 --- a/tests/acp/test_new_session.py +++ b/tests/acp/test_new_session.py @@ -3,29 +3,32 @@ from __future__ import annotations from pathlib import Path from unittest.mock import AsyncMock, patch -from acp import RequestError -from acp.schema import ClientCapabilities +from acp import NewSessionResponse import pytest from tests.acp.conftest import _create_acp_agent from tests.conftest import build_test_vibe_config -from vibe.acp.acp_agent_loop import WORKSPACE_TRUST_CAPABILITY, VibeAcpAgentLoop -from vibe.acp.exceptions import InvalidRequestError +from vibe.acp.acp_agent_loop import VibeAcpAgentLoop from vibe.core.agent_loop import AgentLoop from vibe.core.agents.models import BuiltinAgentName from vibe.core.config import ModelConfig +from vibe.core.feedback import _CACHE_SECTION, _LAST_SHOWN_KEY, record_feedback_asked from vibe.core.trusted_folders import trusted_folders_manager -def _system_prompt(acp_agent_loop: VibeAcpAgentLoop, session_id: str) -> str: +async def _system_prompt(acp_agent_loop: VibeAcpAgentLoop, session_id: str) -> str: session = acp_agent_loop.sessions[session_id] + await session.agent_loop.wait_until_ready() return session.agent_loop.messages[0].content or "" -def _enable_workspace_trust(acp_agent_loop: VibeAcpAgentLoop) -> None: - acp_agent_loop.client_capabilities = ClientCapabilities( - field_meta={WORKSPACE_TRUST_CAPABILITY: True} - ) +def _workspace_trust_meta(session_response: NewSessionResponse) -> dict: + payload = session_response.model_dump(mode="json", by_alias=True) + meta = payload.get("_meta") + assert isinstance(meta, dict) + workspace_trust = meta.get("workspace_trust") + assert isinstance(workspace_trust, dict) + return workspace_trust @pytest.fixture @@ -153,7 +156,27 @@ class TestACPNewSession: assert len(thinking_config.options) == 5 @pytest.mark.asyncio - async def test_new_session_loads_root_agents_md_from_workspace_cwd( + async def test_new_session_uses_file_backed_feedback_cache( + self, acp_agent_loop: VibeAcpAgentLoop, config_dir: Path + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + acp_session = acp_agent_loop.sessions[session_response.session_id] + + record_feedback_asked(acp_session.agent_loop.cache_store) + + cache_path = config_dir / "cache.toml" + assert cache_path.exists() + assert ( + acp_session.agent_loop.cache_store.read_section(_CACHE_SECTION)[ + _LAST_SHOWN_KEY + ] + > 0 + ) + + @pytest.mark.asyncio + async def test_new_session_returns_actionable_trust_details_without_prompting( self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path, @@ -162,7 +185,6 @@ class TestACPNewSession: (tmp_working_directory / "AGENTS.md").write_text( "Root project instructions", encoding="utf-8" ) - _enable_workspace_trust(acp_agent_loop) request_trust = AsyncMock(return_value={"decision": "trust_cwd"}) monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust) @@ -171,26 +193,24 @@ class TestACPNewSession: ) assert session_response.session_id is not None - request_trust.assert_awaited_once() - await_args = request_trust.await_args - assert await_args is not None - method, params = await_args.args - assert method == "trust/request" - assert params["cwd"] == str(tmp_working_directory.resolve()) - assert params["detectedFiles"] == ["AGENTS.md"] - assert params["repoDetectedFiles"] == [] - assert params["availableDecisions"] == ["trust_cwd", "trust_session", "decline"] - assert trusted_folders_manager.is_trusted(tmp_working_directory) is True - assert "Root project instructions" in _system_prompt( + request_trust.assert_not_awaited() + assert _workspace_trust_meta(session_response) == { + "status": "untrusted", + "details": { + "cwd": str(tmp_working_directory.resolve()), + "repoRoot": None, + "ignoredFiles": ["AGENTS.md"], + "availableDecisions": ["trust_cwd", "trust_session", "decline"], + }, + } + assert trusted_folders_manager.is_trusted(tmp_working_directory) is None + assert "Root project instructions" not in await _system_prompt( acp_agent_loop, session_response.session_id ) @pytest.mark.asyncio - async def test_new_session_can_trust_full_repo_from_subdirectory( - self, - acp_agent_loop: VibeAcpAgentLoop, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, + async def test_new_session_returns_repo_trust_details_from_subdirectory( + self, acp_agent_loop: VibeAcpAgentLoop, tmp_path: Path ) -> None: repo = tmp_path / "repo" cwd = repo / "src" / "pkg" @@ -198,79 +218,68 @@ class TestACPNewSession: (repo / ".git" / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8") cwd.mkdir(parents=True) (repo / "AGENTS.md").write_text("Repo instructions", encoding="utf-8") - _enable_workspace_trust(acp_agent_loop) - request_trust = AsyncMock(return_value={"decision": "trust_repo"}) - monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust) session_response = await acp_agent_loop.new_session( cwd=str(cwd), mcp_servers=[] ) assert session_response.session_id is not None - request_trust.assert_awaited_once() - await_args = request_trust.await_args - assert await_args is not None - method, params = await_args.args - assert method == "trust/request" - assert params["cwd"] == str(cwd.resolve()) - assert params["repoRoot"] == str(repo.resolve()) - assert params["detectedFiles"] == [] - assert params["repoDetectedFiles"] == ["AGENTS.md"] - assert params["availableDecisions"] == [ - "trust_repo", - "trust_cwd", - "trust_session", - "decline", - ] - assert trusted_folders_manager.is_trusted(repo) is True - assert trusted_folders_manager.is_trusted(cwd) is True - assert "Repo instructions" in _system_prompt( + assert _workspace_trust_meta(session_response) == { + "status": "untrusted", + "details": { + "cwd": str(cwd.resolve()), + "repoRoot": str(repo.resolve()), + "ignoredFiles": ["AGENTS.md"], + "availableDecisions": [ + "trust_repo", + "trust_cwd", + "trust_session", + "decline", + ], + }, + } + assert trusted_folders_manager.is_trusted(repo) is None + assert trusted_folders_manager.is_trusted(cwd) is None + assert "Repo instructions" not in await _system_prompt( acp_agent_loop, session_response.session_id ) @pytest.mark.asyncio - async def test_new_session_decline_skips_project_docs( - self, - acp_agent_loop: VibeAcpAgentLoop, - tmp_working_directory: Path, - monkeypatch: pytest.MonkeyPatch, + async def test_new_session_returns_details_after_explicit_decline( + self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path ) -> None: (tmp_working_directory / "AGENTS.md").write_text( "Do not load this", encoding="utf-8" ) - _enable_workspace_trust(acp_agent_loop) - monkeypatch.setattr( - acp_agent_loop.client, - "ext_method", - AsyncMock(return_value={"decision": "decline"}), - ) + trusted_folders_manager.add_untrusted(tmp_working_directory) session_response = await acp_agent_loop.new_session( cwd=str(tmp_working_directory), mcp_servers=[] ) assert session_response.session_id is not None + assert _workspace_trust_meta(session_response) == { + "status": "untrusted", + "details": { + "cwd": str(tmp_working_directory.resolve()), + "repoRoot": None, + "ignoredFiles": ["AGENTS.md"], + "availableDecisions": ["trust_cwd", "trust_session", "decline"], + }, + } assert trusted_folders_manager.is_trusted(tmp_working_directory) is False - assert "Do not load this" not in _system_prompt( + assert "Do not load this" not in await _system_prompt( acp_agent_loop, session_response.session_id ) @pytest.mark.asyncio async def test_new_session_session_trust_loads_docs_without_persisting( - self, - acp_agent_loop: VibeAcpAgentLoop, - tmp_working_directory: Path, - monkeypatch: pytest.MonkeyPatch, + self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path ) -> None: (tmp_working_directory / "AGENTS.md").write_text( "Session-only instructions", encoding="utf-8" ) - _enable_workspace_trust(acp_agent_loop) - monkeypatch.setattr( - acp_agent_loop.client, - "ext_method", - AsyncMock(return_value={"decision": "trust_session"}), - ) + trusted_folders_manager.trust_for_session(tmp_working_directory) session_response = await acp_agent_loop.new_session( cwd=str(tmp_working_directory), mcp_servers=[] @@ -278,10 +287,14 @@ class TestACPNewSession: assert session_response.session_id is not None normalized = str(tmp_working_directory.resolve()) + assert _workspace_trust_meta(session_response) == { + "status": "session", + "details": None, + } assert trusted_folders_manager.is_trusted(tmp_working_directory) is True assert normalized in trusted_folders_manager._session_trusted assert normalized not in trusted_folders_manager._trusted - assert "Session-only instructions" in _system_prompt( + assert "Session-only instructions" in await _system_prompt( acp_agent_loop, session_response.session_id ) @@ -292,90 +305,45 @@ class TestACPNewSession: tmp_working_directory: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - _enable_workspace_trust(acp_agent_loop) - request_trust = AsyncMock(return_value={"decision": "trust_cwd"}) - monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust) - - await acp_agent_loop.new_session(cwd=str(tmp_working_directory), mcp_servers=[]) - - request_trust.assert_not_awaited() - assert trusted_folders_manager.is_trusted(tmp_working_directory) is None - - @pytest.mark.asyncio - async def test_new_session_direct_client_fallback_skips_project_docs( - self, - acp_agent_loop: VibeAcpAgentLoop, - tmp_working_directory: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - (tmp_working_directory / "AGENTS.md").write_text( - "Direct client should skip this", encoding="utf-8" - ) - _enable_workspace_trust(acp_agent_loop) - monkeypatch.setattr( - acp_agent_loop.client, - "ext_method", - AsyncMock(side_effect=RequestError.method_not_found("trust/request")), - ) - - session_response = await acp_agent_loop.new_session( - cwd=str(tmp_working_directory), mcp_servers=[] - ) - assert session_response.session_id is not None - - assert trusted_folders_manager.is_trusted(tmp_working_directory) is None - assert "Direct client should skip this" not in _system_prompt( - acp_agent_loop, session_response.session_id - ) - - @pytest.mark.asyncio - async def test_new_session_without_workspace_trust_capability_skips_prompt( - self, - acp_agent_loop: VibeAcpAgentLoop, - tmp_working_directory: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - (tmp_working_directory / "AGENTS.md").write_text( - "Unsupported client should skip this", encoding="utf-8" - ) request_trust = AsyncMock(return_value={"decision": "trust_cwd"}) monkeypatch.setattr(acp_agent_loop.client, "ext_method", request_trust) session_response = await acp_agent_loop.new_session( cwd=str(tmp_working_directory), mcp_servers=[] ) - assert session_response.session_id is not None request_trust.assert_not_awaited() - assert trusted_folders_manager.is_trusted(tmp_working_directory) is None - assert "Unsupported client should skip this" not in _system_prompt( - acp_agent_loop, session_response.session_id + assert _workspace_trust_meta(session_response) == { + "status": "untrusted", + "details": None, + } + assert ( + trusted_folders_manager.trust_status(tmp_working_directory) == "untrusted" ) + assert trusted_folders_manager.is_trusted(tmp_working_directory) is None @pytest.mark.asyncio - async def test_new_session_cancelled_trust_prompt_cancels_session_creation( - self, - acp_agent_loop: VibeAcpAgentLoop, - tmp_working_directory: Path, - monkeypatch: pytest.MonkeyPatch, + async def test_new_session_trusted_folder_loads_docs_with_no_details( + self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path ) -> None: (tmp_working_directory / "AGENTS.md").write_text( - "Cancelled prompt", encoding="utf-8" + "Trusted folder instructions", encoding="utf-8" ) - _enable_workspace_trust(acp_agent_loop) - monkeypatch.setattr( - acp_agent_loop.client, - "ext_method", - AsyncMock(return_value={"decision": "cancelled"}), + trusted_folders_manager.add_trusted(tmp_working_directory) + + session_response = await acp_agent_loop.new_session( + cwd=str(tmp_working_directory), mcp_servers=[] ) + assert session_response.session_id is not None - with pytest.raises(InvalidRequestError): - await acp_agent_loop.new_session( - cwd=str(tmp_working_directory), mcp_servers=[] - ) - - assert trusted_folders_manager.is_trusted(tmp_working_directory) is None - assert acp_agent_loop.sessions == {} + assert trusted_folders_manager.is_trusted(tmp_working_directory) is True + assert _workspace_trust_meta(session_response) == { + "status": "trusted", + "details": None, + } + assert "Trusted folder instructions" in await _system_prompt( + acp_agent_loop, session_response.session_id + ) @pytest.mark.skip(reason="TODO: Fix this test") @pytest.mark.asyncio diff --git a/tests/acp/test_set_model.py b/tests/acp/test_set_model.py index 4967c08..b19bcfa 100644 --- a/tests/acp/test_set_model.py +++ b/tests/acp/test_set_model.py @@ -235,6 +235,7 @@ class TestACPSetModel: (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None ) assert acp_session is not None + await acp_session.agent_loop.wait_until_ready() user_msg = LLMMessage(role=Role.user, content="Hello") assistant_msg = LLMMessage(role=Role.assistant, content="Hi there!") diff --git a/tests/acp/test_tool_call_session_update.py b/tests/acp/test_tool_call_session_update.py index 5211e73..2b7d08d 100644 --- a/tests/acp/test_tool_call_session_update.py +++ b/tests/acp/test_tool_call_session_update.py @@ -40,3 +40,32 @@ class TestToolCallSessionUpdate: assert update.tool_call_id == "test_call_123" assert update.kind == "read" assert update.raw_input is None + + def test_whole_file_read_emits_plain_file_location(self) -> None: + event = self._create_event() + + update = tool_call_session_update(event) + + assert isinstance(update, ToolCallStart) + assert update.locations is not None + location = update.locations[0] + assert location.field_meta == {"type": "file"} + assert location.line is None + + def test_bounded_read_emits_file_range_location(self) -> None: + event = ToolCallEvent( + tool_name="read", + tool_call_id="test_call_123", + args=ReadArgs(file_path="/tmp/test.txt", offset=10, limit=20), + tool_class=Read, + ) + + update = tool_call_session_update(event) + + assert isinstance(update, ToolCallStart) + assert update.locations is not None + location = update.locations[0] + assert location.field_meta is not None + assert location.field_meta["type"] == "file_range" + assert location.field_meta["offset"] == 10 + assert location.field_meta["limit"] == 20 diff --git a/tests/acp/test_tool_field_meta.py b/tests/acp/test_tool_field_meta.py index 94d6906..ee29bdc 100644 --- a/tests/acp/test_tool_field_meta.py +++ b/tests/acp/test_tool_field_meta.py @@ -112,14 +112,25 @@ class TestReadFieldMeta: assert loc.field_meta == {"type": "file_range", "offset": 10, "limit": 50} assert update.field_meta == {"tool_name": "read"} - def test_call_defaults_offset_none_limit_default(self) -> None: + def test_whole_file_read_emits_plain_file_location(self) -> None: event = _call_event("read", Read, ReadArgs(file_path="/tmp/f.txt")) update = tool_call_session_update(event) assert isinstance(update, ToolCallStart) assert update.locations is not None loc = update.locations[0] - assert loc.field_meta == {"type": "file_range", "offset": None, "limit": 2000} + assert loc.field_meta == {"type": "file"} + assert loc.line is None + + def test_read_from_offset_to_end_emits_file_location_with_line(self) -> None: + event = _call_event("read", Read, ReadArgs(file_path="/tmp/f.txt", offset=42)) + update = tool_call_session_update(event) + + assert isinstance(update, ToolCallStart) + assert update.locations is not None + loc = update.locations[0] + assert loc.field_meta == {"type": "file"} + assert loc.line == 42 def test_result_location_has_start_line_and_num_lines(self) -> None: result = ReadResult( @@ -128,6 +139,7 @@ class TestReadFieldMeta: num_lines=3, start_line=10, total_lines=20, + requested_limit=50, ) event = _result_event("read", Read, result) update = tool_result_session_update(event) @@ -137,6 +149,77 @@ class TestReadFieldMeta: loc = update.locations[0] assert loc.field_meta == {"type": "file_range", "offset": 10, "limit": 3} + def test_whole_file_result_emits_plain_file_location(self) -> None: + result = ReadResult( + file_path="/tmp/f.txt", + content=" 1→line1\n 2→line2", + num_lines=2, + start_line=1, + total_lines=2, + ) + event = _result_event("read", Read, result) + update = tool_result_session_update(event) + + assert isinstance(update, ToolCallProgress) + assert update.locations is not None + loc = update.locations[0] + assert loc.field_meta == {"type": "file"} + assert loc.line is None + + def test_truncated_default_limit_result_emits_range(self) -> None: + # No limit given, but the file exceeded the default limit: the read was + # partial, so the chip must show the range, not imply the whole file. + result = ReadResult( + file_path="/tmp/f.txt", + content=" 1→line1", + num_lines=2000, + start_line=1, + total_lines=None, + was_truncated=True, + ) + event = _result_event("read", Read, result) + update = tool_result_session_update(event) + + assert isinstance(update, ToolCallProgress) + assert update.locations is not None + loc = update.locations[0] + assert loc.field_meta == {"type": "file_range", "offset": 1, "limit": 2000} + + def test_offset_to_end_result_emits_file_location_with_line(self) -> None: + result = ReadResult( + file_path="/tmp/f.txt", + content=" 42→line42", + num_lines=1, + start_line=42, + total_lines=42, + requested_offset=42, + ) + event = _result_event("read", Read, result) + update = tool_result_session_update(event) + + assert isinstance(update, ToolCallProgress) + assert update.locations is not None + loc = update.locations[0] + assert loc.field_meta == {"type": "file"} + assert loc.line == 42 + + def test_bounded_result_clamps_limit_to_lines_read(self) -> None: + result = ReadResult( + file_path="/tmp/f.txt", + content=" 1→line1", + num_lines=1, + start_line=1, + total_lines=1, + requested_limit=100, + ) + event = _result_event("read", Read, result) + update = tool_result_session_update(event) + + assert isinstance(update, ToolCallProgress) + assert update.locations is not None + loc = update.locations[0] + assert loc.field_meta == {"type": "file_range", "offset": 1, "limit": 1} + class TestWebSearchFieldMeta: def test_call_meta_contains_query(self) -> None: diff --git a/tests/acp/test_user_display_content.py b/tests/acp/test_user_display_content.py new file mode 100644 index 0000000..b8373fa --- /dev/null +++ b/tests/acp/test_user_display_content.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from acp.schema import TextContentBlock +from pydantic import ValidationError +import pytest + +from tests.stubs.fake_backend import FakeBackend +from tests.stubs.fake_client import FakeClient +from vibe.acp.acp_agent_loop import VibeAcpAgentLoop +from vibe.acp.exceptions import InvalidRequestError +from vibe.acp.user_display_content import ( + USER_DISPLAY_CONTENT_META_KEY, + parse_user_display_content_metadata, +) +from vibe.core.session.session_loader import SessionLoader +from vibe.core.types import Role, UserDisplayContentMetadata + + +def _metadata_payload() -> dict[str, object]: + return { + "version": "1.0.0", + "host": "mistral-vscode", + "content": [ + {"type": "text", "text": "Look at "}, + { + "type": "workspace_mention", + "kind": "file", + "uri": "file:///repo/src/app.ts", + "name": "app.ts", + }, + ], + } + + +def _metadata_kwargs(value: object) -> dict[str, Any]: + return {USER_DISPLAY_CONTENT_META_KEY: value} + + +def test_user_display_content_meta_key_is_snake_case() -> None: + assert USER_DISPLAY_CONTENT_META_KEY == "user_display_content" + + +def test_parse_returns_none_when_metadata_is_missing() -> None: + assert parse_user_display_content_metadata(None) is None + + +def test_parse_validates_present_metadata() -> None: + metadata = parse_user_display_content_metadata({ + "version": "1.0.0", + "host": "mistral-vscode", + "content": [], + }) + + assert metadata == UserDisplayContentMetadata( + version="1.0.0", host="mistral-vscode", content=[] + ) + + +def test_parse_rejects_invalid_metadata() -> None: + with pytest.raises(ValidationError): + parse_user_display_content_metadata({ + "version": 2, + "host": "mistral-vscode", + "content": [], + }) + + +@pytest.mark.asyncio +async def test_prompt_attaches_user_display_content_to_user_message( + acp_agent_loop: VibeAcpAgentLoop, backend: FakeBackend +) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + payload = _metadata_payload() + + response = await acp_agent_loop.prompt( + prompt=[TextContentBlock(type="text", text="Look at app.ts")], + session_id=session_response.session_id, + **_metadata_kwargs(payload), + ) + + assert response.stop_reason == "end_turn" + user_message = next( + (msg for msg in backend.requests_messages[0] if msg.role == Role.user), None + ) + assert user_message is not None + assert user_message.content == "Look at app.ts" + assert ( + user_message.user_display_content + == UserDisplayContentMetadata.model_validate(payload) + ) + + +@pytest.mark.asyncio +async def test_prompt_rejects_invalid_user_display_content( + acp_agent_loop: VibeAcpAgentLoop, +) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + + with pytest.raises(InvalidRequestError, match="Invalid user display content"): + await acp_agent_loop.prompt( + prompt=[TextContentBlock(type="text", text="Look at app.ts")], + session_id=session_response.session_id, + **_metadata_kwargs({"version": 2, "host": "mistral-vscode", "content": []}), + ) + + +@pytest.mark.asyncio +async def test_prompt_persists_user_display_content( + acp_agent_with_session_config: tuple[VibeAcpAgentLoop, FakeClient], + temp_session_dir: Path, +) -> None: + acp_agent, _client = acp_agent_with_session_config + session_response = await acp_agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + payload = _metadata_payload() + + await acp_agent.prompt( + prompt=[TextContentBlock(type="text", text="Look at app.ts")], + session_id=session_response.session_id, + **_metadata_kwargs(payload), + ) + + session_dir = next(temp_session_dir.glob("session_*")) + messages_file = session_dir / "messages.jsonl" + messages = [ + json.loads(line) + for line in messages_file.read_text(encoding="utf-8").splitlines() + ] + user_message = next(msg for msg in messages if msg["role"] == "user") + + assert user_message["user_display_content"] == payload + + loaded_messages, _metadata = SessionLoader.load_session(session_dir) + loaded_user_message = next(msg for msg in loaded_messages if msg.role == Role.user) + assert loaded_user_message.user_display_content == ( + UserDisplayContentMetadata.model_validate(payload) + ) diff --git a/tests/acp/test_utils.py b/tests/acp/test_utils.py index 49f0fbb..decc51a 100644 --- a/tests/acp/test_utils.py +++ b/tests/acp/test_utils.py @@ -1,14 +1,32 @@ from __future__ import annotations +from acp.schema import ToolCallStart + +from vibe.acp.tools.builtins.task import Task as AcpTask +from vibe.acp.tools.builtins.todo import Todo as AcpTodo, TodoArgs +from vibe.acp.user_display_content import USER_DISPLAY_CONTENT_META_KEY from vibe.acp.utils import ( TOOL_OPTIONS, ToolOption, build_permission_options, + create_tool_call_replay, + create_tool_result_replay, + create_user_message_replay, get_proxy_help_text, + tool_call_replay_update, ) +from vibe.core.llm.format import ResolvedToolCall from vibe.core.paths import GLOBAL_ENV_FILE from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS +from vibe.core.tools.builtins.task import TaskArgs from vibe.core.tools.permissions import PermissionScope, RequiredPermission +from vibe.core.types import ( + FunctionCall, + LLMMessage, + Role, + ToolCall, + UserDisplayContentMetadata, +) def _write_env_file(content: str) -> None: @@ -124,3 +142,126 @@ class TestBuildPermissionOptions: assert reject_once.name == "Deny" assert allow_once.field_meta is None assert reject_once.field_meta is None + + +class TestCreateUserMessageReplay: + def test_replays_plain_text_without_field_meta(self) -> None: + replay = create_user_message_replay( + LLMMessage(role=Role.user, content="Hello", message_id="msg-1") + ) + + assert replay.content.text == "Hello" + assert replay.message_id == "msg-1" + assert replay.field_meta is None + + def test_replays_user_display_content_as_field_meta(self) -> None: + user_display_content = UserDisplayContentMetadata( + version="1.0.0", + host="mistral-vscode", + content=[ + {"type": "text", "text": "Look at "}, + { + "type": "workspace_mention", + "kind": "file", + "uri": "file:///repo/src/app.ts", + "name": "app.ts", + }, + ], + ) + + replay = create_user_message_replay( + LLMMessage( + role=Role.user, + content="Look at app.ts", + message_id="msg-1", + user_display_content=user_display_content, + ) + ) + + assert replay.content.text == "Look at app.ts" + assert replay.field_meta == { + USER_DISPLAY_CONTENT_META_KEY: user_display_content.model_dump(mode="json") + } + + +class TestCreateToolCallReplay: + def test_carries_status_and_resolved_kind(self) -> None: + update = create_tool_call_replay("call_1", "grep", '{"pattern": "foo"}') + + assert update.status == "completed" + assert update.kind == "search" + assert update.field_meta == {"tool_name": "grep"} + assert update.raw_input == '{"pattern": "foo"}' + + def test_unknown_tool_defaults_kind_to_other(self) -> None: + update = create_tool_call_replay("call_2", "mystery_tool", None) + + assert update.status == "completed" + assert update.kind == "other" + assert update.field_meta == {"tool_name": "mystery_tool"} + + +class TestCreateToolResultReplay: + def test_carries_kind_and_tool_name_meta(self) -> None: + msg = LLMMessage( + role=Role.tool, tool_call_id="call_1", name="bash", content="exit 0" + ) + + update = create_tool_result_replay(msg) + + assert update is not None + assert update.kind == "execute" + assert update.status == "completed" + assert update.field_meta == {"tool_name": "bash"} + + def test_returns_none_without_tool_call_id(self) -> None: + msg = LLMMessage(role=Role.tool, name="bash", content="exit 0") + + assert create_tool_result_replay(msg) is None + + +def _tool_call(call_id: str, name: str, arguments: str | None) -> ToolCall: + return ToolCall(id=call_id, function=FunctionCall(name=name, arguments=arguments)) + + +class TestToolCallReplayUpdate: + def test_resolved_task_carries_agent_and_task_meta(self) -> None: + tool_call = _tool_call( + "call_1", "task", '{"agent": "explore", "task": "find the bug"}' + ) + resolved = ResolvedToolCall( + tool_name="task", + tool_class=AcpTask, + validated_args=TaskArgs(agent="explore", task="find the bug"), + call_id="call_1", + ) + + update = tool_call_replay_update(resolved, tool_call) + + assert isinstance(update, ToolCallStart) + assert update.status == "completed" + assert update.field_meta is not None + assert update.field_meta["tool_name"] == "task" + assert update.field_meta["agent"] == "explore" + assert update.field_meta["task"] == "find the bug" + + def test_unresolved_call_falls_back_to_generic_replay(self) -> None: + tool_call = _tool_call("call_2", "grep", '{"pattern": "foo"}') + + update = tool_call_replay_update(None, tool_call) + + assert isinstance(update, ToolCallStart) + assert update.status == "completed" + assert update.kind == "search" + assert update.field_meta == {"tool_name": "grep"} + + def test_hidden_tool_call_is_skipped(self) -> None: + tool_call = _tool_call("call_3", "todo", "{}") + resolved = ResolvedToolCall( + tool_name="todo", + tool_class=AcpTodo, + validated_args=TodoArgs(action="read"), + call_id="call_3", + ) + + assert tool_call_replay_update(resolved, tool_call) is None diff --git a/tests/acp/test_workspace_trust.py b/tests/acp/test_workspace_trust.py new file mode 100644 index 0000000..28b17f3 --- /dev/null +++ b/tests/acp/test_workspace_trust.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from pathlib import Path + +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 vibe.acp.acp_agent_loop import VibeAcpAgentLoop +from vibe.acp.exceptions import InvalidRequestError, SessionNotFoundError +from vibe.core.agent_loop import AgentLoop +from vibe.core.config import ModelConfig +from vibe.core.trusted_folders import trusted_folders_manager + + +async def _system_prompt(acp_agent_loop: VibeAcpAgentLoop, session_id: str) -> str: + session = acp_agent_loop.sessions[session_id] + await session.agent_loop.wait_until_ready() + return session.agent_loop.messages[0].content or "" + + +@pytest.fixture +def acp_agent_loop( + backend: FakeBackend, monkeypatch: pytest.MonkeyPatch +) -> VibeAcpAgentLoop: + config = build_test_vibe_config( + active_model="devstral-latest", + models=[ + ModelConfig( + name="devstral-latest", provider="mistral", alias="devstral-latest" + ), + ModelConfig( + name="devstral-small", provider="mistral", alias="devstral-small" + ), + ], + ) + + class PatchedAgentLoop(AgentLoop): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **{**kwargs, "backend": backend}) + self._base_config = config + self.agent_manager.invalidate_config() + + monkeypatch.setattr("vibe.acp.acp_agent_loop.AgentLoop", PatchedAgentLoop) + return _create_acp_agent() + + +class TestWorkspaceTrustExtMethods: + @pytest.mark.asyncio + async def test_workspace_trust_status_returns_details_even_after_decline( + self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path + ) -> None: + (tmp_working_directory / "AGENTS.md").write_text( + "Trust me later", encoding="utf-8" + ) + trusted_folders_manager.add_untrusted(tmp_working_directory) + + response = await acp_agent_loop.ext_method( + "trust/status", {"cwd": str(tmp_working_directory)} + ) + + assert response == { + "trust_status": "untrusted", + "details": { + "cwd": str(tmp_working_directory.resolve()), + "repoRoot": None, + "ignoredFiles": ["AGENTS.md"], + "availableDecisions": ["trust_cwd", "trust_session", "decline"], + }, + } + + @pytest.mark.asyncio + async def test_workspace_trust_decision_trusts_session_and_reloads_session( + self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path + ) -> None: + (tmp_working_directory / "AGENTS.md").write_text( + "Reloaded session instructions", encoding="utf-8" + ) + + session_response = await acp_agent_loop.new_session( + cwd=str(tmp_working_directory), mcp_servers=[] + ) + assert session_response.session_id is not None + assert "Reloaded session instructions" not in await _system_prompt( + acp_agent_loop, session_response.session_id + ) + + response = await acp_agent_loop.ext_method( + "trust/decision", + { + "cwd": str(tmp_working_directory), + "decision": "trust_session", + "session_id": session_response.session_id, + }, + ) + + assert response == {"trust_status": "session", "details": None} + normalized = str(tmp_working_directory.resolve()) + assert normalized in trusted_folders_manager._session_trusted + assert normalized not in trusted_folders_manager._trusted + assert "Reloaded session instructions" in await _system_prompt( + acp_agent_loop, session_response.session_id + ) + + @pytest.mark.asyncio + async def test_workspace_trust_decision_rejects_unavailable_decision( + self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path + ) -> None: + (tmp_working_directory / "AGENTS.md").write_text( + "No repo decision here", encoding="utf-8" + ) + + with pytest.raises(InvalidRequestError): + await acp_agent_loop.ext_method( + "trust/decision", + {"cwd": str(tmp_working_directory), "decision": "trust_repo"}, + ) + + assert trusted_folders_manager.is_trusted(tmp_working_directory) is None + + @pytest.mark.asyncio + async def test_workspace_trust_decision_rejects_unknown_session_id( + self, acp_agent_loop: VibeAcpAgentLoop, tmp_working_directory: Path + ) -> None: + (tmp_working_directory / "AGENTS.md").write_text( + "Unknown session", encoding="utf-8" + ) + + with pytest.raises(SessionNotFoundError): + await acp_agent_loop.ext_method( + "trust/decision", + { + "cwd": str(tmp_working_directory), + "decision": "trust_session", + "session_id": "missing-session", + }, + ) + + assert trusted_folders_manager.is_trusted(tmp_working_directory) is None diff --git a/tests/core/nuage/__init__.py b/tests/agent_loop/e2e/__init__.py similarity index 100% rename from tests/core/nuage/__init__.py rename to tests/agent_loop/e2e/__init__.py diff --git a/tests/agent_loop/e2e/conftest.py b/tests/agent_loop/e2e/conftest.py new file mode 100644 index 0000000..e41e0b4 --- /dev/null +++ b/tests/agent_loop/e2e/conftest.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from collections.abc import Iterator +import json +from typing import Any + +import httpx +import pytest +import respx + +from tests.conftest import build_test_agent_loop, build_test_vibe_config +from tests.constants import ( + ANTHROPIC_BASE_URL, + ANTHROPIC_MESSAGES_PATH, + CHAT_COMPLETIONS_PATH, + CONNECTORS_BOOTSTRAP_PATH, + MISTRAL_BASE_URL, + OPENAI_BASE_URL, + OPENAI_RESPONSES_PATH, +) +from vibe.core.agent_loop import AgentLoop +from vibe.core.agents.models import BuiltinAgentName +from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig +from vibe.core.llm.backend.factory import BACKEND_FACTORY +from vibe.core.types import AssistantEvent, Backend, BaseEvent + + +def assistant_text(events: list[BaseEvent]) -> str: + return "".join( + e.content for e in events if isinstance(e, AssistantEvent) and e.content + ).strip() + + +@pytest.fixture(autouse=True) +def _git_offline(monkeypatch: pytest.MonkeyPatch) -> None: + # Restrict git to the local file protocol so no test can fetch/push over the + # network, even if a remote is misconfigured to a real URL. + monkeypatch.setenv("GIT_ALLOW_PROTOCOL", "file") + + +def e2e_config(**overrides: Any) -> VibeConfig: + # Defaults give the mistral provider/model (api.mistral.ai, the respx-mocked + # base). The default model reasons, but that only adds reasoning_effort to the + # request, which the mocked responses ignore. + # The e2e harness mocks the connector bootstrap endpoint via respx, so + # connector discovery is enabled (and fast) here unlike the unit-test default. + return build_test_vibe_config( + enabled_tools=overrides.pop("enabled_tools", []), + enable_connectors=overrides.pop("enable_connectors", True), + system_prompt_id="tests", + include_project_context=False, + include_prompt_detail=False, + **overrides, + ) + + +def _generic_e2e_config( + *, name: str, api_base: str, api_style: str, model: str, **overrides: Any +) -> VibeConfig: + provider = ProviderConfig( + name=name, + api_base=api_base, + api_key_env_var=f"{name.upper()}_API_KEY", + api_style=api_style, + backend=Backend.GENERIC, + ) + models = [ModelConfig(name=model, provider=name, alias=name)] + return e2e_config( + active_model=name, models=models, providers=[provider], **overrides + ) + + +def anthropic_e2e_config(**overrides: Any) -> VibeConfig: + return _generic_e2e_config( + name="anthropic", + api_base=ANTHROPIC_BASE_URL, + api_style="anthropic", + model="claude-test", + **overrides, + ) + + +def openai_responses_e2e_config(**overrides: Any) -> VibeConfig: + return _generic_e2e_config( + name="openai", + api_base=f"{OPENAI_BASE_URL}/v1", + api_style="openai-responses", + model="gpt-test", + **overrides, + ) + + +@pytest.fixture +def mock_mistral() -> Iterator[respx.MockRouter]: + with respx.mock(base_url=MISTRAL_BASE_URL, assert_all_called=False) as router: + router.get(CONNECTORS_BOOTSTRAP_PATH).mock( + return_value=httpx.Response(200, json={"connectors": []}) + ) + yield router + + +def build_e2e_agent_loop( + *, config: VibeConfig | None = None, enable_streaming: bool = False, **kwargs: Any +) -> AgentLoop: + resolved_config = config or e2e_config() + provider = resolved_config.providers[0] + # todo now we use build_test_agent_loop for the fake mcp registry. + # Maybe mock http tool calls and instantiate a real registry later for more coverage + return build_test_agent_loop( + config=resolved_config, + agent_name=kwargs.pop("agent_name", BuiltinAgentName.AUTO_APPROVE), + backend=BACKEND_FACTORY[provider.backend](provider=provider), + enable_streaming=enable_streaming, + **kwargs, + ) + + +class ProviderAPI: + """Stubs a provider's completion wire; the AgentLoop stays the subject.""" + + def __init__(self, router: respx.MockRouter, path: str) -> None: + self.route = router.post(path) + + def reply(self, *completions: dict[str, Any]) -> None: + responses = [httpx.Response(200, json=c) for c in completions] + if len(responses) == 1: + self.route.mock(return_value=responses[0]) + else: + self.route.mock(side_effect=responses) + + def reply_stream(self, chunks: list[bytes]) -> None: + self.route.mock(return_value=self._stream_response(chunks)) + + def reply_streams(self, *chunk_lists: list[bytes]) -> None: + self.route.mock( + side_effect=[self._stream_response(chunks) for chunks in chunk_lists] + ) + + @staticmethod + def _stream_response(chunks: list[bytes]) -> httpx.Response: + return httpx.Response( + 200, + stream=httpx.ByteStream(stream=b"\n\n".join(chunks)), + headers={"Content-Type": "text/event-stream"}, + ) + + @property + def request_json(self) -> dict[str, Any]: + return json.loads(self.route.calls.last.request.content) + + +class MistralAPI(ProviderAPI): + def __init__(self, router: respx.MockRouter) -> None: + super().__init__(router, CHAT_COMPLETIONS_PATH) + + +@pytest.fixture +def mistral_api(mock_mistral: respx.MockRouter) -> MistralAPI: + return MistralAPI(mock_mistral) + + +@pytest.fixture +def mock_anthropic() -> Iterator[respx.MockRouter]: + with respx.mock(base_url=ANTHROPIC_BASE_URL, assert_all_called=False) as router: + yield router + + +@pytest.fixture +def anthropic_api(mock_anthropic: respx.MockRouter) -> ProviderAPI: + return ProviderAPI(mock_anthropic, ANTHROPIC_MESSAGES_PATH) + + +@pytest.fixture +def mock_openai() -> Iterator[respx.MockRouter]: + with respx.mock(base_url=OPENAI_BASE_URL, assert_all_called=False) as router: + yield router + + +@pytest.fixture +def openai_responses_api(mock_openai: respx.MockRouter) -> ProviderAPI: + return ProviderAPI(mock_openai, OPENAI_RESPONSES_PATH) diff --git a/tests/agent_loop/e2e/test_e2e_agent_loop.py b/tests/agent_loop/e2e/test_e2e_agent_loop.py new file mode 100644 index 0000000..a67941c --- /dev/null +++ b/tests/agent_loop/e2e/test_e2e_agent_loop.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from tests.agent_loop.e2e.conftest import ( + MistralAPI, + assistant_text, + build_e2e_agent_loop, + e2e_config, +) +from tests.backend.data.mistral import ( + STREAMED_SIMPLE_CONVERSATION_PARAMS, + mistral_completion, +) +from vibe.core.types import ( + AssistantEvent, + ToolCallEvent, + ToolResultEvent, + UserMessageEvent, +) + +TODO_TOOL_CALL = [ + { + "id": "call_todo_1", + "function": {"name": "todo", "arguments": '{"action": "read"}'}, + "index": 0, + } +] + + +def _chunked_completion(reasoning: str, answer: str) -> dict[str, Any]: + completion = mistral_completion(answer) + completion["choices"][0]["message"]["content"] = [ + {"type": "thinking", "thinking": [{"type": "text", "text": reasoning}]}, + {"type": "text", "text": answer}, + ] + return completion + + +@pytest.mark.asyncio +async def test_act_completes_against_real_backend(mistral_api: MistralAPI) -> None: + # A plain prompt yields a user event then the backend's assistant reply, and + # the loop accumulates prompt+completion tokens from the usage block. + mistral_api.reply( + mistral_completion("Hi there!", prompt_tokens=120, completion_tokens=30) + ) + agent = build_e2e_agent_loop() + + events = [event async for event in agent.act("Hello")] + + assert [type(e) for e in events] == [UserMessageEvent, AssistantEvent] + assert isinstance(events[1], AssistantEvent) + assert events[1].content == "Hi there!" + assert agent.stats.context_tokens == 150 + + +@pytest.mark.asyncio +async def test_act_streaming(mistral_api: MistralAPI) -> None: + # Streamed SSE chunks are reassembled into the final assistant content. + _, chunks, _ = STREAMED_SIMPLE_CONVERSATION_PARAMS[0] + mistral_api.reply_stream(chunks) + agent = build_e2e_agent_loop(enable_streaming=True) + + events = [event async for event in agent.act("Hi")] + + assert assistant_text(events).endswith("Some content") + + +@pytest.mark.asyncio +async def test_act_tool_call_round_trip(mistral_api: MistralAPI) -> None: + # A tool call is parsed, executed, and its result fed back for a final reply. + mistral_api.reply( + mistral_completion("", tool_calls=TODO_TOOL_CALL), + mistral_completion("All done"), + ) + agent = build_e2e_agent_loop(config=e2e_config(enabled_tools=["todo"])) + + events = [event async for event in agent.act("Show my todos")] + + assert any(isinstance(e, ToolCallEvent) for e in events) + assert any(isinstance(e, ToolResultEvent) for e in events) + final = [e for e in events if isinstance(e, AssistantEvent)] + assert final and final[-1].content == "All done" + + +@pytest.mark.asyncio +async def test_act_serializes_tools_in_request_payload(mistral_api: MistralAPI) -> None: + # Enabled tools are serialized into the outgoing chat-completions request. + mistral_api.reply(mistral_completion("ok")) + agent = build_e2e_agent_loop(config=e2e_config(enabled_tools=["todo"])) + + _ = [event async for event in agent.act("Hello")] + + names = {t["function"]["name"] for t in mistral_api.request_json.get("tools", [])} + assert "todo" in names + + +@pytest.mark.asyncio +async def test_act_extracts_text_from_chunked_content_array( + mistral_api: MistralAPI, +) -> None: + # A thinking+text content array has its text part extracted as the reply. + mistral_api.reply(_chunked_completion("thinking hard", "the answer")) + agent = build_e2e_agent_loop() + + events = [event async for event in agent.act("Why?")] + + assistant = [e for e in events if isinstance(e, AssistantEvent)] + assert assistant and assistant[-1].content == "the answer" diff --git a/tests/agent_loop/e2e/test_e2e_bash.py b/tests/agent_loop/e2e/test_e2e_bash.py new file mode 100644 index 0000000..e4423a0 --- /dev/null +++ b/tests/agent_loop/e2e/test_e2e_bash.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, cast + +from pydantic import BaseModel +import pytest + +from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop, e2e_config +from tests.backend.data.mistral import mistral_completion +from vibe.core.agents.models import BuiltinAgentName +from vibe.core.tools.builtins.bash import BashResult +from vibe.core.tools.permissions import RequiredPermission +from vibe.core.types import ApprovalResponse, BaseEvent, ToolResultEvent + + +def _bash_call(command: str, timeout: int | None = None) -> dict[str, Any]: + arguments: dict[str, Any] = {"command": command} + if timeout is not None: + arguments["timeout"] = timeout + return mistral_completion( + "", + tool_calls=[ + { + "id": "call_bash", + "function": {"name": "bash", "arguments": json.dumps(arguments)}, + "index": 0, + } + ], + ) + + +async def _run_bash( + mistral_api: MistralAPI, + command: str, + *, + timeout: int | None = None, + agent_name: str = BuiltinAgentName.AUTO_APPROVE, + approval: ApprovalResponse | None = None, +) -> ToolResultEvent: + mistral_api.reply(_bash_call(command, timeout), mistral_completion("done")) + agent = build_e2e_agent_loop( + config=e2e_config(enabled_tools=["bash"]), agent_name=agent_name + ) + if approval is not None: + + async def approval_callback( + _tool_name: str, + _args: BaseModel, + _tool_call_id: str, + _rp: list[RequiredPermission] | None = None, + ) -> tuple[ApprovalResponse, str | None]: + return (approval, None) + + agent.set_approval_callback(approval_callback) + + events: list[BaseEvent] = [event async for event in agent.act("go")] + return next(e for e in events if isinstance(e, ToolResultEvent)) + + +@pytest.mark.asyncio +async def test_bash_captures_stdout(mistral_api: MistralAPI) -> None: + result = await _run_bash(mistral_api, "echo hello") + + bash_result = cast(BashResult, result.result) + assert bash_result.returncode == 0 + assert "hello" in bash_result.stdout + + +@pytest.mark.asyncio +async def test_bash_captures_stderr(mistral_api: MistralAPI) -> None: + result = await _run_bash(mistral_api, "echo oops >&2") + + bash_result = cast(BashResult, result.result) + assert "oops" in bash_result.stderr + + +@pytest.mark.asyncio +async def test_bash_nonzero_exit_surfaces_as_error(mistral_api: MistralAPI) -> None: + result = await _run_bash(mistral_api, "exit 3") + + assert result.error is not None + assert "Return code: 3" in result.error + + +@pytest.mark.asyncio +async def test_bash_timeout_surfaces_as_error(mistral_api: MistralAPI) -> None: + result = await _run_bash(mistral_api, "sleep 5", timeout=1) + + assert result.error is not None + assert "timed out" in result.error.lower() + + +@pytest.mark.asyncio +async def test_bash_output_truncated_to_max_bytes(mistral_api: MistralAPI) -> None: + result = await _run_bash(mistral_api, "yes x | head -c 100000") + + bash_result = cast(BashResult, result.result) + assert len(bash_result.stdout) <= 16_000 + + +@pytest.mark.asyncio +async def test_bash_denylisted_command_is_skipped(mistral_api: MistralAPI) -> None: + result = await _run_bash( + mistral_api, "vim file.txt", agent_name=BuiltinAgentName.DEFAULT + ) + + assert result.skipped is True + assert result.skip_reason is not None + assert "denied" in result.skip_reason.lower() + + +@pytest.mark.asyncio +async def test_bash_allowlisted_command_runs_without_approval( + mistral_api: MistralAPI, +) -> None: + # No approval callback registered; an allowlisted command must run anyway. + result = await _run_bash( + mistral_api, "echo allowed", agent_name=BuiltinAgentName.DEFAULT + ) + + assert result.skipped is False + assert "allowed" in cast(BashResult, result.result).stdout + + +@pytest.mark.asyncio +async def test_bash_non_allowlisted_command_requires_approval( + mistral_api: MistralAPI, +) -> None: + result = await _run_bash( + mistral_api, + "touch newfile.txt", + agent_name=BuiltinAgentName.DEFAULT, + approval=ApprovalResponse.YES, + ) + + assert result.skipped is False + assert (Path.cwd() / "newfile.txt").exists() + + +@pytest.mark.asyncio +async def test_bash_non_allowlisted_command_denied_at_prompt_is_skipped( + mistral_api: MistralAPI, +) -> None: + result = await _run_bash( + mistral_api, + "touch denied.txt", + agent_name=BuiltinAgentName.DEFAULT, + approval=ApprovalResponse.NO, + ) + + assert result.skipped is True + assert not (Path.cwd() / "denied.txt").exists() + + +@pytest.mark.asyncio +async def test_bash_command_touching_outside_workdir_requires_approval( + mistral_api: MistralAPI, tmp_path: Path +) -> None: + outside = tmp_path / "outside.txt" + result = await _run_bash( + mistral_api, + f"touch {outside}", + agent_name=BuiltinAgentName.DEFAULT, + approval=ApprovalResponse.NO, + ) + + assert result.skipped is True + assert not outside.exists() diff --git a/tests/agent_loop/e2e/test_e2e_connectors.py b/tests/agent_loop/e2e/test_e2e_connectors.py new file mode 100644 index 0000000..dbb29c5 --- /dev/null +++ b/tests/agent_loop/e2e/test_e2e_connectors.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from typing import Any + +import httpx +import pytest +import respx + +from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop, e2e_config +from tests.backend.data.mistral import mistral_completion +from tests.constants import CONNECTORS_BOOTSTRAP_PATH +from vibe.core.config import ConnectorConfig + + +def _connector( + *, + connector_id: str = "conn-1", + name: str = "wiki", + is_ready: bool = True, + tools: list[dict[str, Any]] | None = None, + auth_action: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "id": connector_id, + "name": name, + "status": {"is_ready": is_ready}, + "tools": tools or [], + "auth_action": auth_action, + } + + +def _tool(name: str = "search", description: str = "Search docs") -> dict[str, Any]: + return { + "name": name, + "description": description, + "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}}, + } + + +def _set_bootstrap(router: respx.MockRouter, connectors: list[dict[str, Any]]) -> None: + router.get(CONNECTORS_BOOTSTRAP_PATH).mock( + return_value=httpx.Response(200, json={"connectors": connectors}) + ) + + +def _connectors_config(*aliases: str) -> Any: + return e2e_config( + connectors=[ConnectorConfig(name=alias, disabled=False) for alias in aliases] + ) + + +async def _offered_tool_names( + mistral_api: MistralAPI, *enabled_connectors: str +) -> set[str]: + # Drive one turn and read which tools the AgentLoop serialized into the + # outgoing chat-completions request — the only connector-visible surface. + mistral_api.reply(mistral_completion("ok")) + agent = build_e2e_agent_loop(config=_connectors_config(*enabled_connectors)) + _ = [event async for event in agent.act("hello")] + return {t["function"]["name"] for t in mistral_api.request_json.get("tools", [])} + + +@pytest.mark.asyncio +async def test_connector_tools_are_offered_to_the_model( + mistral_api: MistralAPI, mock_mistral: respx.MockRouter +) -> None: + _set_bootstrap( + mock_mistral, [_connector(name="wiki", tools=[_tool("search"), _tool("read")])] + ) + + names = await _offered_tool_names(mistral_api, "wiki") + + assert "connector_wiki_search" in names + assert "connector_wiki_read" in names + + +@pytest.mark.asyncio +async def test_colliding_connector_aliases_are_disambiguated( + mistral_api: MistralAPI, mock_mistral: respx.MockRouter +) -> None: + _set_bootstrap( + mock_mistral, + [ + _connector(connector_id="c-1", name="mcp", tools=[_tool("a")]), + _connector(connector_id="c-2", name="mcp", tools=[_tool("b")]), + ], + ) + + names = await _offered_tool_names(mistral_api, "mcp", "mcp_2") + + assert "connector_mcp_a" in names + assert "connector_mcp_2_b" in names + + +@pytest.mark.asyncio +async def test_not_ready_connector_offers_no_tools( + mistral_api: MistralAPI, mock_mistral: respx.MockRouter +) -> None: + _set_bootstrap( + mock_mistral, + [ + _connector( + name="linear", + is_ready=False, + tools=[_tool("search")], + auth_action={"type": "oauth"}, + ) + ], + ) + + names = await _offered_tool_names(mistral_api, "linear") + + assert not any(name.startswith("connector_linear") for name in names) + + +@pytest.mark.asyncio +async def test_disabled_connector_tools_are_withheld( + mistral_api: MistralAPI, mock_mistral: respx.MockRouter +) -> None: + _set_bootstrap(mock_mistral, [_connector(name="wiki", tools=[_tool("search")])]) + + # No ConnectorConfig entry → the connector stays disabled by default. + names = await _offered_tool_names(mistral_api) + + assert "connector_wiki_search" not in names + + +@pytest.mark.asyncio +async def test_bootstrap_failure_still_lets_the_agent_run( + mistral_api: MistralAPI, mock_mistral: respx.MockRouter +) -> None: + mock_mistral.get(CONNECTORS_BOOTSTRAP_PATH).mock(return_value=httpx.Response(500)) + + names = await _offered_tool_names(mistral_api, "wiki") + + assert not any(name.startswith("connector_") for name in names) diff --git a/tests/agent_loop/e2e/test_e2e_fork_and_plan.py b/tests/agent_loop/e2e/test_e2e_fork_and_plan.py new file mode 100644 index 0000000..932405c --- /dev/null +++ b/tests/agent_loop/e2e/test_e2e_fork_and_plan.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop, e2e_config +from tests.backend.data.mistral import mistral_completion +from vibe.core.agents.models import BuiltinAgentName +from vibe.core.tools.builtins.ask_user_question import ( + AskUserQuestionArgs, + AskUserQuestionResult, +) +from vibe.core.types import PlanReviewEndedEvent, PlanReviewRequestedEvent, Role +from vibe.core.utils.tags import VIBE_WARNING_TAG + +EXIT_PLAN_TOOL_CALL = [ + { + "id": "call_exit_plan_1", + "function": {"name": "exit_plan_mode", "arguments": "{}"}, + "index": 0, + } +] + + +def _user_message_ids(agent: Any) -> list[str]: + return [m.message_id for m in agent.messages if m.role == Role.user] + + +def _assistant_message_id(agent: Any) -> str: + return next(m.message_id for m in agent.messages if m.role == Role.assistant) + + +@pytest.mark.asyncio +async def test_fork_copies_all_non_system_messages(mistral_api: MistralAPI) -> None: + # fork() with no anchor clones every non-system message into the child loop. + mistral_api.reply(mistral_completion("Hi there!")) + agent = build_e2e_agent_loop() + _ = [event async for event in agent.act("Hello")] + + forked = await agent.fork() + + non_system = [m for m in forked.messages if m.role != Role.system] + assert [m.role for m in non_system] == [Role.user, Role.assistant] + assert forked.parent_session_id == agent.session_id + assert forked.session_id != agent.session_id + + +@pytest.mark.asyncio +async def test_fork_from_message_id_truncates_at_next_user_turn( + mistral_api: MistralAPI, +) -> None: + # Forking from a user message keeps that turn but drops everything from the next one. + mistral_api.reply(mistral_completion("First"), mistral_completion("Second")) + agent = build_e2e_agent_loop() + _ = [event async for event in agent.act("Turn one")] + _ = [event async for event in agent.act("Turn two")] + + first_user_id = _user_message_ids(agent)[0] + forked = await agent.fork(first_user_id) + + contents = [m.content for m in forked.messages if m.role != Role.system] + assert contents == ["Turn one", "First"] + + +@pytest.mark.asyncio +async def test_fork_from_unknown_message_id_raises(mistral_api: MistralAPI) -> None: + # An unknown anchor id is rejected rather than silently forking everything. + mistral_api.reply(mistral_completion("Hi")) + agent = build_e2e_agent_loop() + _ = [event async for event in agent.act("Hello")] + + with pytest.raises(ValueError, match="unknown message_id"): + await agent.fork("does-not-exist") + + +@pytest.mark.asyncio +async def test_fork_from_assistant_message_id_raises(mistral_api: MistralAPI) -> None: + # Forking is only allowed from user turns; an assistant anchor is rejected. + mistral_api.reply(mistral_completion("Hi")) + agent = build_e2e_agent_loop() + _ = [event async for event in agent.act("Hello")] + + assistant_id = _assistant_message_id(agent) + with pytest.raises(ValueError, match="only supported for user messages"): + await agent.fork(assistant_id) + + +def _plan_agent(mistral_api: MistralAPI) -> Any: + mistral_api.reply( + mistral_completion("", tool_calls=EXIT_PLAN_TOOL_CALL), + mistral_completion("Staying in plan mode."), + ) + return build_e2e_agent_loop( + config=e2e_config(enabled_tools=["exit_plan_mode"]), + agent_name=BuiltinAgentName.PLAN, + ) + + +@pytest.mark.asyncio +async def test_plan_mode_emits_review_requested_and_ended_events( + mistral_api: MistralAPI, +) -> None: + # An exit_plan_mode round trip brackets the tool with review-requested/ended events. + agent = _plan_agent(mistral_api) + + async def stay_in_plan(_: AskUserQuestionArgs) -> AskUserQuestionResult: + return AskUserQuestionResult(cancelled=True, answers=[]) + + agent.set_user_input_callback(stay_in_plan) + + events = [event async for event in agent.act("Make a plan")] + + assert any(isinstance(e, PlanReviewRequestedEvent) for e in events) + assert any(isinstance(e, PlanReviewEndedEvent) for e in events) + + +@pytest.mark.asyncio +async def test_plan_mode_injects_updated_plan_when_file_changed( + mistral_api: MistralAPI, +) -> None: + # If the plan file changes during review, its new content is injected back as context. + agent = _plan_agent(mistral_api) + + plan_path: Path | None = None + + async def edit_plan_then_decline(_: AskUserQuestionArgs) -> AskUserQuestionResult: + assert plan_path is not None + plan_path.parent.mkdir(parents=True, exist_ok=True) + plan_path.write_text("# Updated plan\nStep 1") + return AskUserQuestionResult(cancelled=True, answers=[]) + + agent.set_user_input_callback(edit_plan_then_decline) + + async for event in agent.act("Make a plan"): + if isinstance(event, PlanReviewRequestedEvent): + plan_path = event.file_path + + injected = [m for m in agent.messages if getattr(m, "injected", False)] + assert any( + m.content and VIBE_WARNING_TAG in m.content and "# Updated plan" in m.content + for m in injected + ) diff --git a/tests/agent_loop/e2e/test_e2e_provider_apis.py b/tests/agent_loop/e2e/test_e2e_provider_apis.py new file mode 100644 index 0000000..f40ea20 --- /dev/null +++ b/tests/agent_loop/e2e/test_e2e_provider_apis.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import pytest + +from tests.agent_loop.e2e.conftest import ( + ProviderAPI, + anthropic_e2e_config, + assistant_text, + build_e2e_agent_loop, + openai_responses_e2e_config, +) +from tests.backend.data.anthropic import ( + anthropic_message, + anthropic_reasoning_tool_use_stream, + anthropic_request_content_blocks, + anthropic_text_stream, + anthropic_tool_use, +) +from tests.backend.data.openai_responses import ( + openai_function_call_item, + openai_message_item, + openai_reasoning_tool_call_stream, + openai_response, + openai_text_stream, +) +from vibe.core.types import ToolResultEvent + + +class TestAnthropic: + @pytest.mark.asyncio + async def test_agent_answers(self, anthropic_api: ProviderAPI) -> None: + # A plain prompt is answered through the Anthropic messages wire. + anthropic_api.reply(anthropic_message("pong")) + agent = build_e2e_agent_loop(config=anthropic_e2e_config()) + + events = [event async for event in agent.act("Reply with exactly: pong")] + + assert assistant_text(events) == "pong" + assert agent.stats.context_tokens == 15 + + @pytest.mark.asyncio + async def test_agent_streams(self, anthropic_api: ProviderAPI) -> None: + # Anthropic SSE deltas are reassembled into the final assistant content. + anthropic_api.reply_stream(anthropic_text_stream("pong")) + agent = build_e2e_agent_loop( + config=anthropic_e2e_config(), enable_streaming=True + ) + + events = [event async for event in agent.act("Reply with exactly: pong")] + + assert assistant_text(events) == "pong" + + @pytest.mark.asyncio + async def test_agent_executes_tool_call(self, anthropic_api: ProviderAPI) -> None: + # A tool_use turn runs the tool, then Anthropic returns the final answer. + anthropic_api.reply( + anthropic_tool_use("todo", {"action": "read"}), + anthropic_message("Your list is empty."), + ) + agent = build_e2e_agent_loop( + config=anthropic_e2e_config(enabled_tools=["todo"]) + ) + + events = [event async for event in agent.act("What's on my todo list?")] + + assert any(isinstance(e, ToolResultEvent) for e in events) + assert "Your list is empty." in assistant_text(events) + + @pytest.mark.asyncio + async def test_agent_streams_reasoning_and_tool_call( + self, anthropic_api: ProviderAPI + ) -> None: + # A streamed thinking + tool_use turn runs the tool, then replays that + # reasoning/tool history into the follow-up Anthropic request. + anthropic_api.reply_streams( + anthropic_reasoning_tool_use_stream("todo", '{"action": "read"}'), + anthropic_text_stream("Your list is empty."), + ) + agent = build_e2e_agent_loop( + config=anthropic_e2e_config(enabled_tools=["todo"]), enable_streaming=True + ) + + events = [event async for event in agent.act("What's on my todo list?")] + + assert "Your list is empty." in assistant_text(events) + blocks = anthropic_request_content_blocks(anthropic_api.request_json) + thinking = [b["thinking"] for b in blocks if b.get("type") == "thinking"] + tool_uses = [b for b in blocks if b.get("type") == "tool_use"] + + assert any("thinking..." in text for text in thinking) + assert tool_uses + + +class TestOpenAIResponses: + @pytest.mark.asyncio + async def test_agent_answers(self, openai_responses_api: ProviderAPI) -> None: + # A plain prompt is answered through the OpenAI Responses wire. + openai_responses_api.reply(openai_response([openai_message_item("pong")])) + agent = build_e2e_agent_loop(config=openai_responses_e2e_config()) + + events = [event async for event in agent.act("Reply with exactly: pong")] + + assert assistant_text(events) == "pong" + assert agent.stats.context_tokens == 12 + + @pytest.mark.asyncio + async def test_agent_streams(self, openai_responses_api: ProviderAPI) -> None: + # OpenAI Responses SSE deltas are reassembled into the final content. + openai_responses_api.reply_stream(openai_text_stream("pong")) + agent = build_e2e_agent_loop( + config=openai_responses_e2e_config(), enable_streaming=True + ) + + events = [event async for event in agent.act("Reply with exactly: pong")] + + assert assistant_text(events) == "pong" + + @pytest.mark.asyncio + async def test_agent_captures_reasoning( + self, openai_responses_api: ProviderAPI + ) -> None: + # Commentary phase output is captured as reasoning on the assistant message. + openai_responses_api.reply( + openai_response( + [ + openai_message_item("Let me think.", phase="commentary"), + openai_message_item("pong", phase="final_answer"), + ], + output_tokens=5, + ) + ) + agent = build_e2e_agent_loop(config=openai_responses_e2e_config()) + + events = [event async for event in agent.act("Reply with exactly: pong")] + + assert assistant_text(events) == "pong" + assert any( + m.reasoning_content and "Let me think." in m.reasoning_content + for m in agent.messages + ) + + @pytest.mark.asyncio + async def test_agent_executes_tool_call( + self, openai_responses_api: ProviderAPI + ) -> None: + # A function_call item runs the tool, then OpenAI returns the final answer. + openai_responses_api.reply( + openai_response([openai_function_call_item("todo", '{"action": "read"}')]), + openai_response([openai_message_item("Your list is empty.")]), + ) + agent = build_e2e_agent_loop( + config=openai_responses_e2e_config(enabled_tools=["todo"]) + ) + + events = [event async for event in agent.act("What's on my todo list?")] + + assert any(isinstance(e, ToolResultEvent) for e in events) + assert "Your list is empty." in assistant_text(events) + + @pytest.mark.asyncio + async def test_agent_streams_reasoning_and_tool_call( + self, openai_responses_api: ProviderAPI + ) -> None: + # A streamed commentary + function_call turn runs the tool, then OpenAI + # streams the final answer on the follow-up request. + openai_responses_api.reply_streams( + openai_reasoning_tool_call_stream("todo", '{"action": "read"}'), + openai_text_stream("Your list is empty."), + ) + agent = build_e2e_agent_loop( + config=openai_responses_e2e_config(enabled_tools=["todo"]), + enable_streaming=True, + ) + + events = [event async for event in agent.act("What's on my todo list?")] + + assert any(isinstance(e, ToolResultEvent) for e in events) + assert "Your list is empty." in assistant_text(events) diff --git a/tests/agent_loop/e2e/test_e2e_teleport.py b/tests/agent_loop/e2e/test_e2e_teleport.py new file mode 100644 index 0000000..bc32cf6 --- /dev/null +++ b/tests/agent_loop/e2e/test_e2e_teleport.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +from git import Repo +import httpx +import pytest +import respx + +from tests.agent_loop.e2e.conftest import build_e2e_agent_loop +from tests.constants import ( + CONNECTORS_BOOTSTRAP_PATH, + MISTRAL_BASE_URL, + TELEPORT_COMPLETE_URL, + TELEPORT_SESSIONS_PATH, +) +from vibe.core.agent_loop import AgentLoop, TeleportError +from vibe.core.teleport.types import ( + TeleportCheckingGitEvent, + TeleportCompleteEvent, + TeleportPushingEvent, + TeleportPushRequiredEvent, + TeleportPushResponseEvent, + TeleportStartingWorkflowEvent, +) + +# vibe_code_sessions_base_url is an exclude=True field, so it is dropped when the +# agent manager re-derives config via model_dump(); the default url is unavoidable. +SESSIONS_BASE_URL = "https://chat.mistral.ai" +SESSIONS_URL = f"{SESSIONS_BASE_URL}{TELEPORT_SESSIONS_PATH}" + + +def _sessions_ok() -> httpx.Response: + return httpx.Response( + 200, + json={ + "sessionId": "controller-session-id", + "webSessionId": "web-session-id", + "projectId": "project-id", + "status": "running", + "url": TELEPORT_COMPLETE_URL, + }, + ) + + +def _commit(repo: Repo, message: str) -> str: + (Path(repo.working_dir) / "file.txt").write_text(f"{message}\n") + repo.index.add(["file.txt"]) + repo.index.commit(message) + return repo.head.commit.hexsha + + +def _init_repo(workdir: Path) -> Repo: + # origin is a local bare repo so fetch/push stay offline and instant; a + # separate github-url remote satisfies Teleport's GitHub-only detection. + bare = Repo.init(workdir.with_name(f"{workdir.name}_origin.git"), bare=True) + repo = Repo.init(workdir, initial_branch="work") + repo.config_writer().set_value("user", "name", "Tester").release() + repo.config_writer().set_value("user", "email", "t@example.com").release() + repo.create_remote("origin", str(bare.git_dir)) + repo.create_remote("hub", "https://github.com/owner/repo.git") + return repo + + +def _repo_with_pushed_branch(workdir: Path) -> Repo: + repo = _init_repo(workdir) + _commit(repo, "initial") + repo.git.push("origin", "work") + return repo + + +@pytest.fixture +def mock_sessions() -> Iterator[respx.MockRouter]: + # One router stubs both hosts: the connector bootstrap (api.mistral.ai, hit on + # agent init) and the teleport sessions endpoint (chat.mistral.ai). + with respx.mock(assert_all_called=False) as router: + router.get(f"{MISTRAL_BASE_URL}{CONNECTORS_BOOTSTRAP_PATH}").mock( + return_value=httpx.Response(200, json={"connectors": []}) + ) + router.post(SESSIONS_URL).mock(return_value=_sessions_ok()) + yield router + + +async def _drain( + agent: AgentLoop, prompt: str | None, *, approve: bool | None = None +) -> list[object]: + gen = agent.teleport_to_vibe_code(prompt) + events: list[object] = [] + response: TeleportPushResponseEvent | None = None + while True: + try: + event = await gen.asend(response) + except StopAsyncIteration: + break + events.append(event) + response = None + if isinstance(event, TeleportPushRequiredEvent) and approve is not None: + response = TeleportPushResponseEvent(approved=approve) + return events + + +@pytest.mark.asyncio +async def test_teleport_completes_when_branch_already_pushed( + tmp_working_directory: Path, mock_sessions: respx.MockRouter +) -> None: + _repo_with_pushed_branch(tmp_working_directory) + + events = await _drain(build_e2e_agent_loop(), "do the thing") + + assert [type(e) for e in events] == [ + TeleportCheckingGitEvent, + TeleportStartingWorkflowEvent, + TeleportCompleteEvent, + ] + assert isinstance(events[-1], TeleportCompleteEvent) + assert events[-1].url == TELEPORT_COMPLETE_URL + + +@pytest.mark.asyncio +async def test_teleport_sends_repo_metadata_and_diff( + tmp_working_directory: Path, mock_sessions: respx.MockRouter +) -> None: + repo = _repo_with_pushed_branch(tmp_working_directory) + commit = repo.head.commit.hexsha + (tmp_working_directory / "file.txt").write_text("uncommitted change\n") + + await _drain(build_e2e_agent_loop(), "ship it") + + payload = mock_sessions.calls.last.request.read().decode() + assert "https://github.com/owner/repo.git" in payload + assert "work" in payload + assert commit in payload + assert "zstd" in payload + + +@pytest.mark.asyncio +async def test_teleport_pushes_then_completes_when_approved( + tmp_working_directory: Path, mock_sessions: respx.MockRouter +) -> None: + repo = _repo_with_pushed_branch(tmp_working_directory) + head = _commit(repo, "second") + + events = await _drain(build_e2e_agent_loop(), "ship it", approve=True) + + assert [type(e) for e in events] == [ + TeleportCheckingGitEvent, + TeleportPushRequiredEvent, + TeleportPushingEvent, + TeleportStartingWorkflowEvent, + TeleportCompleteEvent, + ] + assert repo.remote("origin").refs["work"].commit.hexsha == head + + +@pytest.mark.asyncio +async def test_teleport_aborts_when_push_declined( + tmp_working_directory: Path, mock_sessions: respx.MockRouter +) -> None: + repo = _repo_with_pushed_branch(tmp_working_directory) + _commit(repo, "second") + + with pytest.raises(TeleportError, match="not pushed"): + await _drain(build_e2e_agent_loop(), "ship it", approve=False) + + assert not mock_sessions.post(SESSIONS_URL).called + + +@pytest.mark.asyncio +async def test_teleport_fails_when_push_fails( + tmp_working_directory: Path, mock_sessions: respx.MockRouter +) -> None: + repo = _repo_with_pushed_branch(tmp_working_directory) + _commit(repo, "second") + repo.git.remote("set-url", "--push", "origin", "/nonexistent/repo.git") + + with pytest.raises(TeleportError, match="Failed to push"): + await _drain(build_e2e_agent_loop(), "ship it", approve=True) + + +@pytest.mark.asyncio +async def test_teleport_requires_a_branch( + tmp_working_directory: Path, mock_sessions: respx.MockRouter +) -> None: + repo = _repo_with_pushed_branch(tmp_working_directory) + repo.git.checkout(repo.head.commit.hexsha) # detach HEAD + + with pytest.raises(TeleportError, match="checked-out branch"): + await _drain(build_e2e_agent_loop(), "ship it") + + +@pytest.mark.asyncio +async def test_teleport_rejects_empty_prompt( + tmp_working_directory: Path, mock_sessions: respx.MockRouter +) -> None: + repo = _init_repo(tmp_working_directory) + _commit(repo, "initial") + + with pytest.raises(TeleportError, match="non-empty prompt"): + await _drain(build_e2e_agent_loop(), None) + + +@pytest.mark.asyncio +async def test_teleport_surfaces_http_error( + tmp_working_directory: Path, mock_sessions: respx.MockRouter +) -> None: + _repo_with_pushed_branch(tmp_working_directory) + mock_sessions.post(SESSIONS_URL).mock(return_value=httpx.Response(500, text="boom")) + + with pytest.raises(TeleportError, match="start failed"): + await _drain(build_e2e_agent_loop(), "ship it") + + +@pytest.mark.asyncio +async def test_teleport_unsupported_without_github_remote( + tmp_working_directory: Path, mock_sessions: respx.MockRouter +) -> None: + repo = Repo.init(tmp_working_directory, initial_branch="work") + repo.config_writer().set_value("user", "name", "Tester").release() + repo.config_writer().set_value("user", "email", "t@example.com").release() + _commit(repo, "initial") + + with pytest.raises(TeleportError, match="GitHub"): + await _drain(build_e2e_agent_loop(), "ship it") diff --git a/tests/agent_loop/e2e/test_e2e_tools.py b/tests/agent_loop/e2e/test_e2e_tools.py new file mode 100644 index 0000000..f75f111 --- /dev/null +++ b/tests/agent_loop/e2e/test_e2e_tools.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, cast + +import pytest + +from tests.agent_loop.e2e.conftest import MistralAPI, build_e2e_agent_loop, e2e_config +from tests.backend.data.mistral import mistral_completion +from vibe.core.tools.builtins.grep import GrepResult +from vibe.core.tools.builtins.read import ReadResult +from vibe.core.tools.builtins.todo import TodoResult +from vibe.core.types import BaseEvent, ToolResultEvent + + +def _tool_call(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + return mistral_completion( + "", + tool_calls=[ + { + "id": f"call_{name}", + "function": {"name": name, "arguments": json.dumps(arguments)}, + "index": 0, + } + ], + ) + + +async def _run_tool( + mistral_api: MistralAPI, name: str, arguments: dict[str, Any] +) -> ToolResultEvent: + # Drive one tool call against the real cwd, then a final reply, and return + # the single tool result for the test to assert on. + mistral_api.reply(_tool_call(name, arguments), mistral_completion("done")) + agent = build_e2e_agent_loop(config=e2e_config(enabled_tools=[name])) + events: list[BaseEvent] = [event async for event in agent.act("go")] + result = next(e for e in events if isinstance(e, ToolResultEvent)) + assert result.error is None + return result + + +@pytest.mark.asyncio +async def test_write_file_tool_creates_file(mistral_api: MistralAPI) -> None: + target = Path.cwd() / "note.txt" + + await _run_tool(mistral_api, "write_file", {"path": str(target), "content": "hi\n"}) + + assert target.read_text() == "hi\n" + + +@pytest.mark.asyncio +async def test_read_tool_returns_file_content(mistral_api: MistralAPI) -> None: + target = Path.cwd() / "note.txt" + target.write_text("hello world\n") + + result = await _run_tool(mistral_api, "read", {"file_path": str(target)}) + + assert "hello world" in cast(ReadResult, result.result).content + + +@pytest.mark.asyncio +async def test_edit_tool_replaces_text(mistral_api: MistralAPI) -> None: + target = Path.cwd() / "note.txt" + target.write_text("hello world\n") + + await _run_tool( + mistral_api, + "edit", + {"file_path": str(target), "old_string": "hello", "new_string": "goodbye"}, + ) + + assert target.read_text() == "goodbye world\n" + + +@pytest.mark.asyncio +async def test_grep_tool_finds_matches(mistral_api: MistralAPI) -> None: + (Path.cwd() / "note.txt").write_text("needle here\n") + + result = await _run_tool(mistral_api, "grep", {"pattern": "needle", "path": "."}) + + assert cast(GrepResult, result.result).match_count >= 1 + + +@pytest.mark.asyncio +async def test_todo_tool_writes_items(mistral_api: MistralAPI) -> None: + result = await _run_tool( + mistral_api, + "todo", + {"action": "write", "todos": [{"id": "1", "content": "ship it"}]}, + ) + + assert cast(TodoResult, result.result).total_count == 1 diff --git a/tests/test_agent_auto_compact.py b/tests/agent_loop/test_agent_auto_compact.py similarity index 100% rename from tests/test_agent_auto_compact.py rename to tests/agent_loop/test_agent_auto_compact.py diff --git a/tests/test_agent_auto_title.py b/tests/agent_loop/test_agent_auto_title.py similarity index 100% rename from tests/test_agent_auto_title.py rename to tests/agent_loop/test_agent_auto_title.py diff --git a/tests/test_agent_backend.py b/tests/agent_loop/test_agent_backend.py similarity index 100% rename from tests/test_agent_backend.py rename to tests/agent_loop/test_agent_backend.py diff --git a/tests/test_agent_loop_images.py b/tests/agent_loop/test_agent_loop_images.py similarity index 96% rename from tests/test_agent_loop_images.py rename to tests/agent_loop/test_agent_loop_images.py index b6f1f35..51be6f9 100644 --- a/tests/test_agent_loop_images.py +++ b/tests/agent_loop/test_agent_loop_images.py @@ -9,7 +9,7 @@ from tests.mock.utils import mock_llm_chunk from tests.stubs.fake_backend import FakeBackend from vibe.core.agent_loop import ImagesNotSupportedError from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig -from vibe.core.types import Backend, ImageAttachment, LLMMessage, Role +from vibe.core.types import Backend, FileImageSource, ImageAttachment, LLMMessage, Role PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 @@ -68,7 +68,9 @@ def _config_with_both_models() -> VibeConfig: def png_attachment(tmp_path: Path) -> ImageAttachment: p = tmp_path / "x.png" p.write_bytes(PNG_BYTES) - return ImageAttachment(path=p, alias="x.png", mime_type="image/png") + return ImageAttachment( + source=FileImageSource(path=p), alias="x.png", mime_type="image/png" + ) @pytest.mark.asyncio diff --git a/tests/test_agent_loop_refresh_system_prompt.py b/tests/agent_loop/test_agent_loop_refresh_system_prompt.py similarity index 100% rename from tests/test_agent_loop_refresh_system_prompt.py rename to tests/agent_loop/test_agent_loop_refresh_system_prompt.py diff --git a/tests/test_agent_observer_streaming.py b/tests/agent_loop/test_agent_observer_streaming.py similarity index 100% rename from tests/test_agent_observer_streaming.py rename to tests/agent_loop/test_agent_observer_streaming.py diff --git a/tests/test_agent_override_resolve_permission.py b/tests/agent_loop/test_agent_override_resolve_permission.py similarity index 100% rename from tests/test_agent_override_resolve_permission.py rename to tests/agent_loop/test_agent_override_resolve_permission.py diff --git a/tests/test_agent_stats.py b/tests/agent_loop/test_agent_stats.py similarity index 100% rename from tests/test_agent_stats.py rename to tests/agent_loop/test_agent_stats.py diff --git a/tests/test_agent_tool_call.py b/tests/agent_loop/test_agent_tool_call.py similarity index 100% rename from tests/test_agent_tool_call.py rename to tests/agent_loop/test_agent_tool_call.py diff --git a/tests/test_agents.py b/tests/agent_loop/test_agents.py similarity index 100% rename from tests/test_agents.py rename to tests/agent_loop/test_agents.py diff --git a/tests/test_approve_always_permanent.py b/tests/agent_loop/test_approve_always_permanent.py similarity index 100% rename from tests/test_approve_always_permanent.py rename to tests/agent_loop/test_approve_always_permanent.py diff --git a/tests/test_deferred_init.py b/tests/agent_loop/test_deferred_init.py similarity index 96% rename from tests/test_deferred_init.py rename to tests/agent_loop/test_deferred_init.py index 93ea6af..9929cec 100644 --- a/tests/test_deferred_init.py +++ b/tests/agent_loop/test_deferred_init.py @@ -18,6 +18,7 @@ from vibe.core.agent_loop import AgentLoop from vibe.core.config import MCPStdio from vibe.core.telemetry.types import TerminalEmulator from vibe.core.tools.manager import ToolManager +from vibe.core.tools.mcp import AuthStatus from vibe.core.tools.mcp.tools import RemoteTool @@ -157,6 +158,23 @@ class TestIntegrateMcpIdempotency: # so a future call with servers would still run discovery. assert not manager._mcp_integrated + def test_no_servers_syncs_shared_registry_status(self) -> None: + config = build_test_vibe_config( + mcp_servers=[MCPStdio(name="srv", transport="stdio", command="echo")] + ) + registry = FakeMCPRegistry() + manager = ToolManager(lambda: config, mcp_registry=registry, defer_mcp=True) + + manager.integrate_mcp() + assert registry.status() == {"srv": AuthStatus.STDIO} + + config = build_test_vibe_config(mcp_servers=[]) + manager = ToolManager(lambda: config, mcp_registry=registry, defer_mcp=True) + manager.integrate_mcp() + + assert registry.status() == {} + assert not manager._mcp_integrated + class TestRefreshRemoteTools: @pytest.mark.asyncio diff --git a/tests/autocompletion/test_path_completion_controller.py b/tests/autocompletion/test_path_completion_controller.py index 7378b76..177cb28 100644 --- a/tests/autocompletion/test_path_completion_controller.py +++ b/tests/autocompletion/test_path_completion_controller.py @@ -24,7 +24,9 @@ class StubView(CompletionView): def clear_completion_suggestions(self) -> None: self.clears += 1 - def replace_completion_range(self, start: int, end: int, replacement: str) -> None: + def replace_completion_range( + self, start: int, end: int, replacement: str, *, suppress_update: bool = False + ) -> None: self.replacements.append((start, end, replacement)) diff --git a/tests/autocompletion/test_slash_command_controller.py b/tests/autocompletion/test_slash_command_controller.py index 2bedef9..2afcf37 100644 --- a/tests/autocompletion/test_slash_command_controller.py +++ b/tests/autocompletion/test_slash_command_controller.py @@ -40,7 +40,9 @@ class StubView(CompletionView): def clear_completion_suggestions(self) -> None: self.reset_count += 1 - def replace_completion_range(self, start: int, end: int, replacement: str) -> None: + def replace_completion_range( + self, start: int, end: int, replacement: str, *, suppress_update: bool = False + ) -> None: self.replacements.append(Replacement(start, end, replacement)) diff --git a/tests/autocompletion/test_ui_chat_autocompletion.py b/tests/autocompletion/test_ui_chat_autocompletion.py index 6bf5dfc..4d825ed 100644 --- a/tests/autocompletion/test_ui_chat_autocompletion.py +++ b/tests/autocompletion/test_ui_chat_autocompletion.py @@ -3,14 +3,13 @@ from __future__ import annotations from pathlib import Path import pytest -from textual.content import Content -from textual.style import Style from textual.widgets import Markdown from vibe.cli.textual_ui.app import VibeApp from vibe.cli.textual_ui.widgets.chat_input.completion_popup import ( CompletionPopup, _CompletionItem, + _CompletionRow, ) from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer @@ -42,7 +41,7 @@ async def test_popup_hides_when_input_cleared(vibe_app: VibeApp) -> None: @pytest.mark.asyncio -async def test_pressing_tab_writes_selected_command_and_leaves_popup_visible( +async def test_pressing_tab_completes_command_and_hides_popup_when_exact_match( vibe_app: VibeApp, ) -> None: async with vibe_app.run_test() as pilot: @@ -53,24 +52,18 @@ async def test_pressing_tab_writes_selected_command_and_leaves_popup_visible( await pilot.press("tab") assert chat_input.value == "/config" - assert popup.styles.display == "block" + assert popup.styles.display == "none" def ensure_selected_command(popup: CompletionPopup, expected_alias: str) -> None: - selected_aliases: list[str] = [] - for item in popup.query(_CompletionItem): - renderable = item.render() - assert isinstance(renderable, Content) - content = str(renderable) - for span in renderable.spans: - style = span.style - if isinstance(style, Style) and style.reverse: - alias_text = content[span.start : span.end].strip() - alias = alias_text.split()[0] if alias_text else "" - selected_aliases.append(alias) - - assert len(selected_aliases) == 1 - assert selected_aliases[0] == expected_alias + selected_rows = [ + row + for row in popup.query(_CompletionRow) + if row.has_class("completion-selected") + ] + assert len(selected_rows) == 1 + command = selected_rows[0].query_one(".completion-command", _CompletionItem) + assert str(command.render()).strip() == expected_alias @pytest.mark.asyncio @@ -297,20 +290,6 @@ async def test_finds_files_recursively_with_partial_path( assert popup.styles.display == "block" -@pytest.mark.asyncio -async def test_popup_is_positioned_near_cursor(vibe_app: VibeApp) -> None: - async with vibe_app.run_test() as pilot: - popup = vibe_app.query_one(CompletionPopup) - - await pilot.press(*"/com") - - assert popup.styles.display == "block" - offset = popup.styles.offset - # The popup should have an explicit offset set by _position_popup - assert offset.x is not None - assert offset.y is not None - - @pytest.mark.asyncio async def test_does_not_trigger_completion_when_navigating_history( file_tree: Path, vibe_app: VibeApp diff --git a/tests/backend/data/anthropic.py b/tests/backend/data/anthropic.py new file mode 100644 index 0000000..5cd9804 --- /dev/null +++ b/tests/backend/data/anthropic.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import json +from typing import Any + +from tests.backend.data import Chunk, JsonResponse + + +def _sse_event(data: dict[str, Any]) -> Chunk: + return f"data: {json.dumps(data, separators=(',', ':'))}".encode() + + +def anthropic_request_content_blocks(payload: dict[str, Any]) -> list[dict[str, Any]]: + # Flatten every structured content block across a request's messages + # (text/thinking/tool_use/tool_result blocks). + return [ + block + for message in payload["messages"] + if isinstance(message["content"], list) + for block in message["content"] + ] + + +def anthropic_message( + text: str, + *, + input_tokens: int = 12, + output_tokens: int = 3, + stop_reason: str = "end_turn", +) -> JsonResponse: + return { + "content": [{"type": "text", "text": text}], + "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens}, + "stop_reason": stop_reason, + } + + +def anthropic_tool_use( + name: str, + tool_input: dict[str, Any], + *, + tool_id: str = "toolu_1", + input_tokens: int = 20, + output_tokens: int = 5, +) -> JsonResponse: + return { + "content": [ + {"type": "tool_use", "id": tool_id, "name": name, "input": tool_input} + ], + "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens}, + "stop_reason": "tool_use", + } + + +def anthropic_reasoning_tool_use_stream( + name: str, + arguments: str, + *, + reasoning: str = "thinking...", + signature: str = "sig", + tool_id: str = "toolu_1", + input_tokens: int = 20, + output_tokens: int = 5, +) -> list[Chunk]: + return [ + _sse_event(e) + for e in ( + { + "type": "message_start", + "message": {"usage": {"input_tokens": input_tokens}}, + }, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": reasoning}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "signature_delta", "signature": signature}, + }, + {"type": "content_block_stop", "index": 0}, + { + "type": "content_block_start", + "index": 1, + "content_block": {"type": "tool_use", "id": tool_id, "name": name}, + }, + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "input_json_delta", "partial_json": arguments}, + }, + {"type": "content_block_stop", "index": 1}, + { + "type": "message_delta", + "delta": {"stop_reason": "tool_use"}, + "usage": {"output_tokens": output_tokens}, + }, + {"type": "message_stop"}, + ) + ] + + +def anthropic_text_stream( + text: str, *, input_tokens: int = 12, output_tokens: int = 3 +) -> list[Chunk]: + return [ + _sse_event(e) + for e in ( + { + "type": "message_start", + "message": {"usage": {"input_tokens": input_tokens}}, + }, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": text}, + }, + {"type": "content_block_stop", "index": 0}, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": {"output_tokens": output_tokens}, + }, + {"type": "message_stop"}, + ) + ] diff --git a/tests/backend/data/mistral.py b/tests/backend/data/mistral.py index 6e6504c..660bb17 100644 --- a/tests/backend/data/mistral.py +++ b/tests/backend/data/mistral.py @@ -1,7 +1,41 @@ from __future__ import annotations +from typing import Any + from tests.backend.data import Chunk, JsonResponse, ResultData, Url + +def mistral_completion( + content: str, + *, + prompt_tokens: int = 100, + completion_tokens: int = 50, + tool_calls: list[dict[str, Any]] | None = None, +) -> JsonResponse: + return { + "id": "cmpl_test", + "created": 1234567890, + "model": "devstral-latest", + "object": "chat.completion", + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + }, + "choices": [ + { + "index": 0, + "finish_reason": "tool_calls" if tool_calls else "stop", + "message": { + "role": "assistant", + "content": content, + "tool_calls": tool_calls, + }, + } + ], + } + + SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [ ( "https://api.mistral.ai", diff --git a/tests/backend/data/openai_responses.py b/tests/backend/data/openai_responses.py index 2e8d580..df83cb4 100644 --- a/tests/backend/data/openai_responses.py +++ b/tests/backend/data/openai_responses.py @@ -4,8 +4,7 @@ import json from typing import Any from tests.backend.data import Chunk, JsonResponse, ResultData, Url - -OPENAI_RESPONSES_TEST_BASE_URL = "https://api.openai.com" +from tests.constants import OPENAI_BASE_URL def _sse_event(data: dict[str, Any] | str) -> Chunk: @@ -90,9 +89,134 @@ def _function_call_item( } +def openai_message_item( + text: str, *, phase: str | None = None, message_id: str = "msg_1" +) -> dict[str, Any]: + return _message_output_item(message_id, text, phase=phase) + + +def openai_function_call_item( + name: str, arguments: str, *, item_id: str = "fc_1", call_id: str = "call_1" +) -> dict[str, Any]: + return _function_call_item(item_id, call_id, name, arguments) + + +def openai_response( + output: list[dict[str, Any]], + *, + input_tokens: int = 10, + output_tokens: int = 2, + response_id: str = "resp_1", + model: str = "gpt-test", +) -> JsonResponse: + return { + "id": response_id, + "object": "response", + "created_at": 1234567890, + "model": model, + "output": output, + "usage": { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens, + }, + } + + +def openai_sse(*events: dict[str, Any] | str) -> list[Chunk]: + return [_sse_event(event) for event in events] + + +def openai_reasoning_tool_call_stream( + name: str, + arguments: str, + *, + reasoning: str = "thinking...", + call_id: str = "call_1", + item_id: str = "fc_1", + input_tokens: int = 20, + output_tokens: int = 5, +) -> list[Chunk]: + return openai_sse( + {"type": "response.created", "response": {"id": "resp_1", "output": []}}, + { + "type": "response.output_item.added", + "output_index": 0, + "item": _stream_message_item("msg_r", phase="commentary"), + }, + { + "type": "response.reasoning_summary_text.delta", + "output_index": 0, + "delta": reasoning, + }, + { + "type": "response.output_item.added", + "output_index": 1, + "item": _function_call_item( + item_id, call_id, name, "", status="in_progress" + ), + }, + { + "type": "response.function_call_arguments.delta", + "output_index": 1, + "call_id": call_id, + "delta": arguments, + }, + { + "type": "response.function_call_arguments.done", + "output_index": 1, + "call_id": call_id, + "name": name, + "arguments": arguments, + }, + { + "type": "response.output_item.done", + "output_index": 1, + "item": _function_call_item(item_id, call_id, name, arguments), + }, + { + "type": "response.completed", + "response": openai_response( + [_function_call_item(item_id, call_id, name, arguments)], + input_tokens=input_tokens, + output_tokens=output_tokens, + ), + }, + "[DONE]", + ) + + +def openai_text_stream( + text: str, *, input_tokens: int = 10, output_tokens: int = 2 +) -> list[Chunk]: + return openai_sse( + {"type": "response.created", "response": {"id": "resp_1", "output": []}}, + { + "type": "response.output_item.added", + "output_index": 0, + "item": _stream_message_item("msg_1"), + }, + { + "type": "response.output_text.delta", + "output_index": 0, + "content_index": 0, + "delta": text, + }, + { + "type": "response.completed", + "response": openai_response( + [openai_message_item(text)], + input_tokens=input_tokens, + output_tokens=output_tokens, + ), + }, + "[DONE]", + ) + + SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [ ( - OPENAI_RESPONSES_TEST_BASE_URL, + OPENAI_BASE_URL, { "id": "resp_fake_id_1234", "object": "response", @@ -113,7 +237,7 @@ SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [ TOOL_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [ ( - OPENAI_RESPONSES_TEST_BASE_URL, + OPENAI_BASE_URL, { "id": "resp_fake_id_9012", "object": "response", @@ -144,7 +268,7 @@ TOOL_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [ STREAMED_SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, list[Chunk], list[ResultData]]] = [ ( - OPENAI_RESPONSES_TEST_BASE_URL, + OPENAI_BASE_URL, [ _sse_event({ "type": "response.created", @@ -235,7 +359,7 @@ STREAMED_SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, list[Chunk], list[ResultDat COMMENTARY_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [ ( - OPENAI_RESPONSES_TEST_BASE_URL, + OPENAI_BASE_URL, { "id": "resp_thinking_1234", "object": "response", @@ -268,7 +392,7 @@ STREAMED_COMMENTARY_CONVERSATION_PARAMS: list[ tuple[Url, list[Chunk], list[ResultData]] ] = [ ( - OPENAI_RESPONSES_TEST_BASE_URL, + OPENAI_BASE_URL, [ _sse_event({ "type": "response.created", @@ -419,7 +543,7 @@ STREAMED_COMMENTARY_CONVERSATION_PARAMS: list[ STREAMED_TOOL_CONVERSATION_PARAMS: list[tuple[Url, list[Chunk], list[ResultData]]] = [ ( - OPENAI_RESPONSES_TEST_BASE_URL, + OPENAI_BASE_URL, [ _sse_event({ "type": "response.created", diff --git a/tests/backend/test_anthropic_adapter.py b/tests/backend/test_anthropic_adapter.py index c3cd03b..35c39ca 100644 --- a/tests/backend/test_anthropic_adapter.py +++ b/tests/backend/test_anthropic_adapter.py @@ -4,6 +4,7 @@ import json import pytest +from tests.constants import ANTHROPIC_BASE_URL, ANTHROPIC_MESSAGES_PATH from vibe.core.config import ProviderConfig from vibe.core.llm.backend.anthropic import AnthropicAdapter, AnthropicMapper from vibe.core.types import ( @@ -30,7 +31,7 @@ def adapter(): def provider(): return ProviderConfig( name="anthropic", - api_base="https://api.anthropic.com", + api_base=ANTHROPIC_BASE_URL, api_key_env_var="ANTHROPIC_API_KEY", api_style="anthropic", ) @@ -229,75 +230,6 @@ class TestMapperParseResponse: assert chunk.usage.completion_tokens == 7 -class TestMapperStreamingEvents: - def test_text_delta(self, mapper): - chunk, idx = mapper.parse_streaming_event( - "content_block_delta", - {"delta": {"type": "text_delta", "text": "hi"}, "index": 0}, - 0, - ) - assert chunk.message.content == "hi" - - def test_thinking_delta(self, mapper): - chunk, _ = mapper.parse_streaming_event( - "content_block_delta", - {"delta": {"type": "thinking_delta", "thinking": "hmm"}, "index": 0}, - 0, - ) - assert chunk.message.reasoning_content == "hmm" - - def test_tool_use_start(self, mapper): - chunk, idx = mapper.parse_streaming_event( - "content_block_start", - { - "content_block": {"type": "tool_use", "id": "t1", "name": "search"}, - "index": 2, - }, - 0, - ) - assert chunk.message.tool_calls[0].id == "t1" - assert idx == 2 - - def test_input_json_delta(self, mapper): - chunk, _ = mapper.parse_streaming_event( - "content_block_delta", - { - "delta": {"type": "input_json_delta", "partial_json": '{"q":'}, - "index": 1, - }, - 0, - ) - assert chunk.message.tool_calls[0].function.arguments == '{"q":' - - def test_message_start_usage(self, mapper): - chunk, _ = mapper.parse_streaming_event( - "message_start", - {"message": {"usage": {"input_tokens": 50, "cache_read_input_tokens": 10}}}, - 0, - ) - assert chunk.usage.prompt_tokens == 60 - - def test_message_delta_usage(self, mapper): - chunk, _ = mapper.parse_streaming_event( - "message_delta", {"usage": {"output_tokens": 42}}, 0 - ) - assert chunk.usage.completion_tokens == 42 - - def test_unknown_event(self, mapper): - chunk, idx = mapper.parse_streaming_event("ping", {}, 5) - assert chunk is None - assert idx == 5 - - def test_signature_delta(self, mapper): - chunk, _ = mapper.parse_streaming_event( - "content_block_delta", - {"delta": {"type": "signature_delta", "signature": "sig"}, "index": 0}, - 0, - ) - assert chunk is not None - assert chunk.message.reasoning_signature == "sig" - - class TestAdapterPrepareRequest: def test_basic(self, adapter, provider): messages = [LLMMessage(role=Role.user, content="Hello")] @@ -316,7 +248,7 @@ class TestAdapterPrepareRequest: assert payload["model"] == "claude-sonnet-4-20250514" assert payload["max_tokens"] == 1024 assert "temperature" not in payload - assert req.endpoint == "/v1/messages" + assert req.endpoint == ANTHROPIC_MESSAGES_PATH assert req.headers["anthropic-version"] == "2023-06-01" def test_beta_features(self, adapter, provider): diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py index ea3199d..ccb66d1 100644 --- a/tests/backend/test_backend.py +++ b/tests/backend/test_backend.py @@ -36,8 +36,9 @@ 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 tests.constants import CHAT_COMPLETIONS_PATH from vibe.core.config import ModelConfig, ProviderConfig -from vibe.core.llm.backend.factory import BACKEND_FACTORY +from vibe.core.llm.backend.factory import BACKEND_FACTORY, create_backend from vibe.core.llm.backend.generic import GenericBackend from vibe.core.llm.backend.mistral import MistralBackend, MistralMapper from vibe.core.llm.exceptions import BackendError, BackendErrorBuilder @@ -71,7 +72,7 @@ class TestBackend: self, base_url: Url, json_response: JsonResponse, result_data: ResultData ): with respx.mock(base_url=base_url) as mock_api: - mock_api.post("/v1/chat/completions").mock( + mock_api.post(CHAT_COMPLETIONS_PATH).mock( return_value=httpx.Response(status_code=200, json=json_response) ) provider = ProviderConfig( @@ -139,7 +140,7 @@ class TestBackend: self, base_url: Url, chunks: list[Chunk], result_data: list[ResultData] ): with respx.mock(base_url=base_url) as mock_api: - mock_api.post("/v1/chat/completions").mock( + mock_api.post(CHAT_COMPLETIONS_PATH).mock( return_value=httpx.Response( status_code=200, stream=httpx.ByteStream(stream=b"\n\n".join(chunks)), @@ -291,7 +292,7 @@ class TestBackend: response: httpx.Response, ): with respx.mock(base_url=base_url) as mock_api: - mock_api.post("/v1/chat/completions").mock(return_value=response) + mock_api.post(CHAT_COMPLETIONS_PATH).mock(return_value=response) provider = ProviderConfig( name="provider_name", api_base=f"{base_url}/v1", @@ -335,7 +336,7 @@ class TestBackend: self, base_url: Url, provider_name: str, expected_stream_options: dict ): with respx.mock(base_url=base_url) as mock_api: - route = mock_api.post("/v1/chat/completions").mock( + route = mock_api.post(CHAT_COMPLETIONS_PATH).mock( return_value=httpx.Response( status_code=200, stream=httpx.ByteStream( @@ -399,7 +400,7 @@ class TestBackend: ], } with respx.mock(base_url=base_url) as mock_api: - mock_api.post("/v1/chat/completions").mock( + mock_api.post(CHAT_COMPLETIONS_PATH).mock( return_value=httpx.Response(status_code=200, json=json_response) ) @@ -441,7 +442,7 @@ class TestBackend: stream=httpx.ByteStream(stream=b"\n\n".join(chunks)), headers={"Content-Type": "text/event-stream"}, ) - mock_api.post("/v1/chat/completions").mock(return_value=mock_response) + mock_api.post(CHAT_COMPLETIONS_PATH).mock(return_value=mock_response) provider = ProviderConfig( name="provider_name", @@ -470,13 +471,29 @@ class TestBackend: class TestMistralRetry: @staticmethod - def _create_test_backend() -> MistralBackend: + def _create_test_backend( + timeout: float = 720.0, retry_max_elapsed_time: float = 300.0 + ) -> MistralBackend: provider = ProviderConfig( name="test_provider", api_base="https://api.mistral.ai/v1", api_key_env_var="API_KEY", ) - return MistralBackend(provider=provider) + return MistralBackend( + provider=provider, + timeout=timeout, + retry_max_elapsed_time=retry_max_elapsed_time, + ) + + @staticmethod + def _build_fast_http_retry_config() -> RetryConfig: + return RetryConfig( + strategy="backoff", + backoff=BackoffStrategy( + initial_interval=1, max_interval=1, exponent=1, max_elapsed_time=10000 + ), + retry_connection_errors=True, + ) @pytest.mark.asyncio async def test_client_creation_includes_timeout_and_retry_config(self): @@ -492,6 +509,61 @@ class TestMistralRetry: assert call_kwargs["retry_config"] is backend._retry_config assert "async_client" in call_kwargs + def test_retry_budget_uses_explicit_config(self): + backend = self._create_test_backend( + timeout=7200.0, retry_max_elapsed_time=1234.0 + ) + + assert backend._timeout == 7200.0 + assert backend._retry_config.backoff.max_elapsed_time == 1234000 + + def test_create_backend_passes_retry_budget(self): + provider = ProviderConfig( + name="test_provider", + api_base="https://api.mistral.ai/v1", + api_key_env_var="API_KEY", + backend=Backend.MISTRAL, + ) + + backend = create_backend( + provider=provider, timeout=7200.0, retry_max_elapsed_time=1234.0 + ) + + assert isinstance(backend, MistralBackend) + assert backend._timeout == 7200.0 + assert backend._retry_config.backoff.max_elapsed_time == 1234000 + + @pytest.mark.asyncio + async def test_complete_retries_retryable_http_error(self): + with respx.mock(base_url="https://api.mistral.ai") as mock_api: + route = mock_api.post("/v1/chat/completions").mock( + side_effect=[ + httpx.Response(status_code=502, text="Bad Gateway"), + httpx.Response( + status_code=200, json=MISTRAL_SIMPLE_CONVERSATION_PARAMS[0][1] + ), + ] + ) + backend = self._create_test_backend() + backend._retry_config = self._build_fast_http_retry_config() + model = ModelConfig( + name="model_name", provider="test_provider", alias="model_alias" + ) + messages = [LLMMessage(role=Role.user, content="Just say hi")] + + result = await backend.complete( + model=model, + messages=messages, + temperature=0.2, + tools=None, + max_tokens=None, + tool_choice=None, + extra_headers=None, + ) + + assert result.message.content == "Some content" + assert route.call_count == 2 + class TestMistralMapperPrepareMessage: """Tests for MistralMapper.prepare_message thinking-block handling. diff --git a/tests/backend/test_image_encoding_cache.py b/tests/backend/test_image_encoding_cache.py index d54bad6..176c7c1 100644 --- a/tests/backend/test_image_encoding_cache.py +++ b/tests/backend/test_image_encoding_cache.py @@ -11,7 +11,7 @@ from vibe.core.llm.backend._image import ( to_base64, to_data_uri, ) -from vibe.core.types import ImageAttachment +from vibe.core.types import FileImageSource, ImageAttachment, InlineImageSource PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 @@ -24,7 +24,9 @@ def _clear_cache() -> None: def _att(tmp_path: Path, name: str = "shot.png") -> ImageAttachment: p = tmp_path / name p.write_bytes(PNG_BYTES) - return ImageAttachment(path=p, alias=name, mime_type="image/png") + return ImageAttachment( + source=FileImageSource(path=p), alias=name, mime_type="image/png" + ) def test_repeated_calls_hit_cache_and_skip_disk(tmp_path: Path) -> None: @@ -46,13 +48,14 @@ def test_cache_invalidates_when_file_mtime_changes(tmp_path: Path) -> None: to_base64(att) assert _encode_cached.cache_info().misses == 1 + assert isinstance(att.source, FileImageSource) new_bytes = PNG_BYTES + b"\x01" - att.path.write_bytes(new_bytes) + att.source.path.write_bytes(new_bytes) # Force a distinct mtime even on coarse-resolution filesystems. import os - stat = att.path.stat() - os.utime(att.path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000)) + stat = att.source.path.stat() + os.utime(att.source.path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000)) refreshed = to_base64(att) assert refreshed == base64.b64encode(new_bytes).decode("ascii") @@ -61,8 +64,23 @@ def test_cache_invalidates_when_file_mtime_changes(tmp_path: Path) -> None: def test_missing_file_raises_image_read_error(tmp_path: Path) -> None: att = ImageAttachment( - path=tmp_path / "nope.png", alias="nope.png", mime_type="image/png" + source=FileImageSource(path=tmp_path / "nope.png"), + alias="nope.png", + mime_type="image/png", ) with pytest.raises(ImageReadError): to_data_uri(att) + + +def test_inline_data_is_returned_without_touching_disk() -> None: + encoded = base64.b64encode(PNG_BYTES).decode("ascii") + att = ImageAttachment( + source=InlineImageSource(data=encoded), + alias="pasted.png", + mime_type="image/png", + ) + + assert to_base64(att) == encoded + assert to_data_uri(att) == f"data:image/png;base64,{encoded}" + assert _encode_cached.cache_info().misses == 0 diff --git a/tests/backend/test_image_mapping.py b/tests/backend/test_image_mapping.py index d9aafe3..1966b0b 100644 --- a/tests/backend/test_image_mapping.py +++ b/tests/backend/test_image_mapping.py @@ -14,7 +14,7 @@ from vibe.core.llm.backend.generic import OpenAIAdapter from vibe.core.llm.backend.mistral import MistralMapper from vibe.core.llm.backend.openai_responses import OpenAIResponsesAdapter from vibe.core.llm.backend.reasoning_adapter import ReasoningAdapter -from vibe.core.types import ImageAttachment, LLMMessage, Role +from vibe.core.types import FileImageSource, ImageAttachment, LLMMessage, Role PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 EXPECTED_B64 = base64.b64encode(PNG_BYTES).decode("ascii") @@ -25,7 +25,9 @@ EXPECTED_DATA_URI = f"data:image/png;base64,{EXPECTED_B64}" def image_attachment(tmp_path: Path) -> ImageAttachment: path = tmp_path / "shot.png" path.write_bytes(PNG_BYTES) - return ImageAttachment(path=path, alias="shot.png", mime_type="image/png") + return ImageAttachment( + source=FileImageSource(path=path), alias="shot.png", mime_type="image/png" + ) def _user_message(image: ImageAttachment) -> LLMMessage: diff --git a/tests/backend/test_openai_responses_adapter.py b/tests/backend/test_openai_responses_adapter.py index 95b46a1..c05ff9d 100644 --- a/tests/backend/test_openai_responses_adapter.py +++ b/tests/backend/test_openai_responses_adapter.py @@ -19,13 +19,13 @@ import respx from tests.backend.data import Chunk, JsonResponse, ResultData, Url from tests.backend.data.openai_responses import ( COMMENTARY_CONVERSATION_PARAMS, - OPENAI_RESPONSES_TEST_BASE_URL, SIMPLE_CONVERSATION_PARAMS, STREAMED_COMMENTARY_CONVERSATION_PARAMS, STREAMED_SIMPLE_CONVERSATION_PARAMS, STREAMED_TOOL_CONVERSATION_PARAMS, TOOL_CONVERSATION_PARAMS, ) +from tests.constants import OPENAI_BASE_URL, OPENAI_RESPONSES_PATH from vibe.core.config import ModelConfig, ProviderConfig from vibe.core.llm.backend.generic import GenericBackend from vibe.core.llm.backend.openai_responses import OpenAIResponsesAdapter @@ -55,7 +55,7 @@ def model(): return _make_model() -def _make_provider(base_url: Url = OPENAI_RESPONSES_TEST_BASE_URL) -> ProviderConfig: +def _make_provider(base_url: Url = OPENAI_BASE_URL) -> ProviderConfig: return ProviderConfig( name="openai", api_base=f"{base_url}/v1", @@ -68,7 +68,7 @@ def _make_model() -> ModelConfig: return ModelConfig(name="gpt-4o", provider="openai", alias="gpt-4o") -def _make_backend(base_url: Url = OPENAI_RESPONSES_TEST_BASE_URL) -> GenericBackend: +def _make_backend(base_url: Url = OPENAI_BASE_URL) -> GenericBackend: return GenericBackend(provider=_make_provider(base_url)) @@ -1229,7 +1229,7 @@ class TestGenericBackendIntegration: self, base_url: Url, json_response: JsonResponse, result_data: ResultData ): with respx.mock(base_url=base_url) as mock_api: - mock_api.post("/v1/responses").mock( + mock_api.post(OPENAI_RESPONSES_PATH).mock( return_value=httpx.Response(status_code=200, json=json_response) ) backend = _make_backend(base_url) @@ -1261,7 +1261,7 @@ class TestGenericBackendIntegration: self, base_url: Url, chunks: list[Chunk], result_data: list[ResultData] ): with respx.mock(base_url=base_url) as mock_api: - mock_api.post("/v1/responses").mock( + mock_api.post(OPENAI_RESPONSES_PATH).mock( return_value=httpx.Response( status_code=200, stream=httpx.ByteStream(stream=b"\n\n".join(chunks)), @@ -1289,9 +1289,9 @@ class TestGenericBackendIntegration: @pytest.mark.asyncio async def test_streaming_payload_includes_stream_flag(self): - base_url = OPENAI_RESPONSES_TEST_BASE_URL + base_url = OPENAI_BASE_URL with respx.mock(base_url=base_url) as mock_api: - route = mock_api.post("/v1/responses").mock( + route = mock_api.post(OPENAI_RESPONSES_PATH).mock( return_value=httpx.Response( status_code=200, stream=httpx.ByteStream( diff --git a/tests/cli/plan_offer/test_decide_plan_offer.py b/tests/cli/plan_offer/test_decide_plan_offer.py index 9b837f0..e3e86b8 100644 --- a/tests/cli/plan_offer/test_decide_plan_offer.py +++ b/tests/cli/plan_offer/test_decide_plan_offer.py @@ -176,6 +176,27 @@ def test_resolve_api_key_for_plan_with_missing_env_var() -> None: environ["MISTRAL_API_KEY"] = previous_api_key +def test_resolve_api_key_for_plan_falls_back_to_keyring( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + keyring_api_key = "keyring_mistral_api_key" + monkeypatch.setattr( + "vibe.core.config._settings.keyring.get_password", + lambda service, username: keyring_api_key, + ) + + provider = ProviderConfig( + name="test_mistral", + api_base="https://api.mistral.ai", + backend=Backend.MISTRAL, + api_key_env_var="MISTRAL_API_KEY", + ) + + result = resolve_api_key_for_plan(provider) + assert result == keyring_api_key + + @pytest.mark.parametrize( ("plan_info", "expected_cta"), [ diff --git a/tests/cli/test_cache.py b/tests/cli/test_cache.py deleted file mode 100644 index 9197e08..0000000 --- a/tests/cli/test_cache.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import tomllib - -from vibe.cli.cache import read_cache, write_cache - - -class TestReadCache: - def test_reads_valid_toml(self, tmp_path: Path) -> None: - cache_path = tmp_path / "cache.toml" - cache_path.write_text('[update_cache]\nlatest_version = "1.0.0"\n') - - result = read_cache(cache_path) - - assert result["update_cache"]["latest_version"] == "1.0.0" - - def test_returns_empty_dict_when_missing(self, tmp_path: Path) -> None: - assert read_cache(tmp_path / "missing.toml") == {} - - def test_returns_empty_dict_when_corrupted(self, tmp_path: Path) -> None: - cache_path = tmp_path / "cache.toml" - cache_path.write_text("{bad toml") - - assert read_cache(cache_path) == {} - - -class TestWriteCache: - def test_writes_new_file(self, tmp_path: Path) -> None: - cache_path = tmp_path / "cache.toml" - - write_cache(cache_path, "feedback", {"last_shown_at": 100.0}) - - with cache_path.open("rb") as f: - data = tomllib.load(f) - assert data["feedback"]["last_shown_at"] == 100.0 - - def test_merges_with_existing(self, tmp_path: Path) -> None: - cache_path = tmp_path / "cache.toml" - cache_path.write_text('[update_cache]\nlatest_version = "1.0.0"\n') - - write_cache(cache_path, "feedback", {"last_shown_at": 200.0}) - - with cache_path.open("rb") as f: - data = tomllib.load(f) - assert data["update_cache"]["latest_version"] == "1.0.0" - assert data["feedback"]["last_shown_at"] == 200.0 - - def test_merges_within_section_and_leaves_other_sections_alone( - self, tmp_path: Path - ) -> None: - cache_path = tmp_path / "cache.toml" - cache_path.write_text( - "[update_cache]\n" - 'latest_version = "1.0.0"\n' - "stored_at_timestamp = 1\n" - 'seen_whats_new_version = "1.0.0"\n\n' - "[feedback]\n" - "last_shown_at = 100.0\n" - ) - - write_cache( - cache_path, - "update_cache", - {"latest_version": "2.0.0", "stored_at_timestamp": 2}, - ) - - with cache_path.open("rb") as f: - data = tomllib.load(f) - assert data["update_cache"]["latest_version"] == "2.0.0" - assert data["update_cache"]["stored_at_timestamp"] == 2 - assert data["update_cache"]["seen_whats_new_version"] == "1.0.0" - assert data["feedback"]["last_shown_at"] == 100.0 diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index d61d6b5..ac90326 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -128,6 +128,14 @@ class TestCommandRegistry: result = registry.parse_command("/connectors filesystem") assert result == ("mcp", registry.commands["mcp"], "filesystem") + def test_mcp_command_description_surfaces_auth_subcommands(self) -> None: + registry = CommandRegistry() + command = registry.commands["mcp"] + + assert "status" in command.description + assert "login " in command.description + assert "logout " in command.description + def test_data_retention_command_registration(self) -> None: registry = CommandRegistry() result = registry.parse_command("/data-retention") diff --git a/tests/cli/test_feedback_bar_manager.py b/tests/cli/test_feedback_bar_manager.py index af8a165..c35627c 100644 --- a/tests/cli/test_feedback_bar_manager.py +++ b/tests/cli/test_feedback_bar_manager.py @@ -6,6 +6,7 @@ import tomllib from unittest.mock import MagicMock, patch from vibe.cli.textual_ui.widgets.feedback_bar_manager import FeedbackBarManager +from vibe.core.cache_store import FileSystemVibeCodeCacheStore from vibe.core.feedback import ( _CACHE_SECTION, _LAST_SHOWN_KEY, @@ -15,24 +16,18 @@ from vibe.core.feedback import ( from vibe.core.types import LLMMessage, Role -def _patch_cache_file(tmp_path: Path): - from vibe.core.paths._vibe_home import GlobalPath - - return patch( - "vibe.core.feedback.CACHE_FILE", GlobalPath(lambda: tmp_path / "cache.toml") - ) - - def _patch_probability(value: float): return patch("vibe.core.feedback.FEEDBACK_PROBABILITY", value) def _make_agent_loop( + cache_path: Path, user_message_count: int = MIN_USER_MESSAGES_FOR_FEEDBACK, telemetry_active: bool = True, ) -> MagicMock: loop = MagicMock() loop.telemetry_client.is_active.return_value = telemetry_active + loop.cache_store = FileSystemVibeCodeCacheStore(cache_path) messages = [ LLMMessage(role=Role.user, content=f"msg {i}") for i in range(user_message_count) @@ -45,20 +40,22 @@ class TestShouldShow: def test_shows_when_conditions_met(self, tmp_path: Path) -> None: manager = FeedbackBarManager() with ( - _patch_cache_file(tmp_path), _patch_probability(0.2), patch("vibe.core.feedback.random.random", return_value=0.0), ): - assert manager.should_show(_make_agent_loop()) is True + assert ( + manager.should_show(_make_agent_loop(tmp_path / "cache.toml")) is True + ) def test_does_not_show_when_random_misses(self, tmp_path: Path) -> None: manager = FeedbackBarManager() with ( - _patch_cache_file(tmp_path), _patch_probability(0.2), patch("vibe.core.feedback.random.random", return_value=1.0), ): - assert manager.should_show(_make_agent_loop()) is False + assert ( + manager.should_show(_make_agent_loop(tmp_path / "cache.toml")) is False + ) def test_does_not_show_within_cooldown(self, tmp_path: Path) -> None: (tmp_path / "cache.toml").write_text( @@ -66,11 +63,12 @@ class TestShouldShow: ) manager = FeedbackBarManager() with ( - _patch_cache_file(tmp_path), _patch_probability(0.2), patch("vibe.core.feedback.random.random", return_value=0.0), ): - assert manager.should_show(_make_agent_loop()) is False + assert ( + manager.should_show(_make_agent_loop(tmp_path / "cache.toml")) is False + ) def test_shows_after_cooldown_expires(self, tmp_path: Path) -> None: (tmp_path / "cache.toml").write_text( @@ -78,30 +76,38 @@ class TestShouldShow: ) manager = FeedbackBarManager() with ( - _patch_cache_file(tmp_path), _patch_probability(0.2), patch("vibe.core.feedback.random.random", return_value=0.0), ): - assert manager.should_show(_make_agent_loop()) is True + assert ( + manager.should_show(_make_agent_loop(tmp_path / "cache.toml")) is True + ) def test_does_not_show_when_telemetry_inactive(self, tmp_path: Path) -> None: manager = FeedbackBarManager() - with _patch_cache_file(tmp_path), _patch_probability(0.2): + with _patch_probability(0.2): assert ( - manager.should_show(_make_agent_loop(telemetry_active=False)) is False + manager.should_show( + _make_agent_loop(tmp_path / "cache.toml", telemetry_active=False) + ) + is False ) def test_does_not_show_when_too_few_user_messages(self, tmp_path: Path) -> None: manager = FeedbackBarManager() with ( - _patch_cache_file(tmp_path), _patch_probability(0.2), patch("vibe.core.feedback.random.random", return_value=0.0), ): - assert manager.should_show(_make_agent_loop(user_message_count=1)) is False + assert ( + manager.should_show( + _make_agent_loop(tmp_path / "cache.toml", user_message_count=1) + ) + is False + ) def test_skips_injected_messages_in_count(self, tmp_path: Path) -> None: - loop = _make_agent_loop(user_message_count=0) + loop = _make_agent_loop(tmp_path / "cache.toml", user_message_count=0) loop.messages = [ LLMMessage(role=Role.user, content="real"), LLMMessage(role=Role.user, content="injected", injected=True), @@ -109,7 +115,6 @@ class TestShouldShow: ] manager = FeedbackBarManager() with ( - _patch_cache_file(tmp_path), _patch_probability(0.2), patch("vibe.core.feedback.random.random", return_value=0.0), ): @@ -121,8 +126,7 @@ class TestRecordFeedbackAsked: def test_writes_timestamp_to_cache(self, tmp_path: Path) -> None: manager = FeedbackBarManager() before = int(time.time()) - with _patch_cache_file(tmp_path): - manager.record_feedback_asked() - with (tmp_path / "cache.toml").open("rb") as f: - data = tomllib.load(f) + manager.record_feedback_asked(_make_agent_loop(tmp_path / "cache.toml")) + with (tmp_path / "cache.toml").open("rb") as f: + data = tomllib.load(f) assert data[_CACHE_SECTION][_LAST_SHOWN_KEY] >= before diff --git a/tests/cli/test_help.py b/tests/cli/test_help.py index 2f8447b..e4d8556 100644 --- a/tests/cli/test_help.py +++ b/tests/cli/test_help.py @@ -16,13 +16,41 @@ def test_help_shows_auto_approve_flag( assert exc_info.value.code == 0 output = capsys.readouterr().out assert "--auto-approve" in output + assert "--yolo" in output assert "Shortcut for --agent auto-approve" in output -def test_auto_approve_conflicts_with_agent( +def test_help_shows_check_upgrade_flag( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - monkeypatch.setattr("sys.argv", ["vibe", "--agent", "plan", "--auto-approve"]) + monkeypatch.setattr("sys.argv", ["vibe", "--help"]) + + with pytest.raises(SystemExit) as exc_info: + parse_arguments() + + assert exc_info.value.code == 0 + output = capsys.readouterr().out + assert "--check-upgrade" in output + assert "Check for a Vibe update now" in output + + +# def test_auto_approve_conflicts_with_agent( +# monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + + +def test_yolo_alias_selects_auto_approve(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("sys.argv", ["vibe", "--yolo"]) + + args = parse_arguments() + + assert args.auto_approve is True + + +@pytest.mark.parametrize("flag", ["--auto-approve", "--yolo"]) +def test_auto_approve_aliases_conflict_with_agent( + flag: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr("sys.argv", ["vibe", "--agent", "plan", flag]) with pytest.raises(SystemExit) as exc_info: parse_arguments() diff --git a/tests/cli/test_programmatic_setup.py b/tests/cli/test_programmatic_setup.py index dd670d9..c62d738 100644 --- a/tests/cli/test_programmatic_setup.py +++ b/tests/cli/test_programmatic_setup.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +from collections.abc import Callable from pathlib import Path import pytest @@ -21,6 +22,7 @@ def _make_args(**overrides: object) -> argparse.Namespace: "enabled_tools": None, "output": "text", "agent": "default", + "check_upgrade": False, "setup": False, "workdir": None, "add_dir": [], @@ -134,16 +136,15 @@ def test_trust_flag_trusts_cwd_for_session_only( args = _make_args(trust=True, prompt=None) monkeypatch.setattr(entrypoint_mod, "parse_arguments", lambda: args) - monkeypatch.setattr( - entrypoint_mod, "check_and_resolve_trusted_folder", lambda _cwd: None - ) monkeypatch.setattr( entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None ) + # Stop main() before it runs the actual CLI. - monkeypatch.setattr( - "vibe.cli.cli.run_cli", lambda _args: (_ for _ in ()).throw(SystemExit(0)) - ) + def fake_run_cli(_args: argparse.Namespace, **_kwargs: object) -> None: + raise SystemExit(0) + + monkeypatch.setattr("vibe.cli.cli.run_cli", fake_run_cli) with pytest.raises(SystemExit) as exc_info: entrypoint_mod.main() @@ -172,9 +173,11 @@ def test_trust_flag_works_in_programmatic_mode( monkeypatch.setattr( entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None ) - monkeypatch.setattr( - "vibe.cli.cli.run_cli", lambda _args: (_ for _ in ()).throw(SystemExit(0)) - ) + + def fake_run_cli(_args: argparse.Namespace, **_kwargs: object) -> None: + raise SystemExit(0) + + monkeypatch.setattr("vibe.cli.cli.run_cli", fake_run_cli) with pytest.raises(SystemExit): entrypoint_mod.main() @@ -183,6 +186,78 @@ def test_trust_flag_works_in_programmatic_mode( assert trusted_folders_manager._trusted == [] +def test_check_upgrade_does_not_pass_trust_resolver( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + project = tmp_path / "proj" + project.mkdir() + (project / "AGENTS.md").write_text("hello", encoding="utf-8") + monkeypatch.chdir(project) + + args = _make_args(prompt=None, check_upgrade=True) + monkeypatch.setattr(entrypoint_mod, "parse_arguments", lambda: args) + monkeypatch.setattr( + entrypoint_mod, + "check_and_resolve_trusted_folder", + lambda _cwd: pytest.fail("check-upgrade must not prompt for trust"), + ) + monkeypatch.setattr( + entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None + ) + + def fake_run_cli( + _args: argparse.Namespace, + *, + resolve_trusted_folder: Callable[[], None] | None = None, + ) -> None: + assert resolve_trusted_folder is None + raise SystemExit(0) + + monkeypatch.setattr("vibe.cli.cli.run_cli", fake_run_cli) + + with pytest.raises(SystemExit) as exc_info: + entrypoint_mod.main() + + assert exc_info.value.code == 0 + + +def test_interactive_start_passes_trust_resolver_to_cli( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + project = tmp_path / "proj" + project.mkdir() + monkeypatch.chdir(project) + + args = _make_args(prompt=None) + calls: list[str] = [] + monkeypatch.setattr(entrypoint_mod, "parse_arguments", lambda: args) + monkeypatch.setattr( + entrypoint_mod, + "check_and_resolve_trusted_folder", + lambda _cwd: calls.append("trust"), + ) + monkeypatch.setattr( + entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None + ) + + def fake_run_cli( + _args: argparse.Namespace, + *, + resolve_trusted_folder: Callable[[], None] | None = None, + ) -> None: + assert callable(resolve_trusted_folder) + resolve_trusted_folder() + raise SystemExit(0) + + monkeypatch.setattr("vibe.cli.cli.run_cli", fake_run_cli) + + with pytest.raises(SystemExit) as exc_info: + entrypoint_mod.main() + + assert exc_info.value.code == 0 + assert calls == ["trust"] + + def test_session_trust_does_not_write_to_disk( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -224,3 +299,60 @@ def test_run_cli_passes_max_tokens_to_run_programmatic( assert exc_info.value.code == 0 assert call["max_session_tokens"] == 123 + + +def test_run_cli_runs_update_prompt_before_trust_resolver( + monkeypatch: pytest.MonkeyPatch, +) -> None: + args = _make_args(prompt=None) + config = build_test_vibe_config() + calls: list[str] = [] + + monkeypatch.setattr(cli_mod, "bootstrap_config_files", lambda: None) + monkeypatch.setattr(cli_mod, "load_config_or_exit", lambda interactive: config) + monkeypatch.setattr( + cli_mod, + "_maybe_run_startup_update_prompt", + lambda _config, _repository: calls.append("update"), + ) + + def resolve_trusted_folder() -> None: + calls.append("trust") + raise SystemExit(0) + + with pytest.raises(SystemExit) as exc_info: + cli_mod.run_cli(args, resolve_trusted_folder=resolve_trusted_folder) + + assert exc_info.value.code == 0 + assert calls == ["update", "trust"] + + +def test_run_cli_check_upgrade_exits_before_loading_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + args = _make_args(prompt=None, check_upgrade=True) + call: dict[str, object] = {} + + monkeypatch.setattr(cli_mod, "bootstrap_config_files", lambda: None) + monkeypatch.setattr( + cli_mod, + "load_config_or_exit", + lambda interactive: pytest.fail("check-upgrade should not load config"), + ) + monkeypatch.setattr(cli_mod, "load_update_prompt_theme", lambda: "dracula") + + def fake_run_check_upgrade(_repository: object, *, theme: str | None) -> None: + call["theme"] = theme + + monkeypatch.setattr(cli_mod, "_run_check_upgrade", fake_run_check_upgrade) + + with pytest.raises(SystemExit) as exc_info: + cli_mod.run_cli( + args, + resolve_trusted_folder=lambda: pytest.fail( + "check-upgrade should not prompt for trust" + ), + ) + + assert exc_info.value.code == 0 + assert call["theme"] == "dracula" diff --git a/tests/cli/test_rename_command.py b/tests/cli/test_rename_command.py index 165c7bf..0cfe9d9 100644 --- a/tests/cli/test_rename_command.py +++ b/tests/cli/test_rename_command.py @@ -2,7 +2,6 @@ from __future__ import annotations import json from pathlib import Path -from unittest.mock import MagicMock, patch import pytest @@ -114,7 +113,7 @@ async def test_resume_picker_shows_renamed_session_title( assert handled is True assert captured_picker is not None - assert captured_picker._latest_messages[f"local:{logger.session_id}"] == "New title" + assert captured_picker._latest_messages[logger.session_id] == "New title" @pytest.mark.asyncio @@ -129,34 +128,3 @@ async def test_rename_command_requires_title(tmp_path: Path) -> None: assert any(error._error == "Usage: /rename " for error in errors) assert handled is True - - -@pytest.mark.asyncio -async def test_rename_command_is_intercepted_for_remote_sessions( - tmp_path: Path, -) -> None: - config = build_test_vibe_config(session_logging=_enabled_session_config(tmp_path)) - app = build_test_vibe_app(config=config) - - async with app.run_test() as pilot: - with patch( - "vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource" - ) as MockSource: - remote_source = MagicMock() - remote_source.session_id = "remote-session-id" - remote_source.is_terminated = False - remote_source.is_waiting_for_input = False - MockSource.return_value = remote_source - - await app._remote_manager.attach( - session_id="remote-session-id", config=config - ) - handled = await app._handle_command("/rename Remote title") - await pilot.pause() - errors = app.query(ErrorMessage) - assert any( - error._error == "Renaming is only supported for local sessions." - for error in errors - ) - - assert handled is True diff --git a/tests/cli/test_session_delete.py b/tests/cli/test_session_delete.py index 24db5db..7c55920 100644 --- a/tests/cli/test_session_delete.py +++ b/tests/cli/test_session_delete.py @@ -57,13 +57,11 @@ async def test_session_delete_request_deletes_local_session( monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll) await app.on_session_picker_app_session_delete_requested( - SessionPickerApp.SessionDeleteRequested( - "local:deleted-session", "local", "deleted-session" - ) + SessionPickerApp.SessionDeleteRequested("deleted-session", "deleted-session") ) assert deleted_sessions == [("deleted-session", config.session_logging)] - assert picker.removed_option_ids == ["local:deleted-session"] + assert picker.removed_option_ids == ["deleted-session"] assert any( isinstance(widget, UserCommandMessage) and widget._content @@ -96,13 +94,11 @@ async def test_session_delete_request_keeps_picker_on_delete_error( monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll) await app.on_session_picker_app_session_delete_requested( - SessionPickerApp.SessionDeleteRequested( - "local:deleted-session", "local", "deleted-session" - ) + SessionPickerApp.SessionDeleteRequested("deleted-session", "deleted-session") ) assert picker.removed_option_ids == [] - assert picker.cleared_pending_option_ids == ["local:deleted-session"] + assert picker.cleared_pending_option_ids == ["deleted-session"] assert any( isinstance(widget, ErrorMessage) and widget._error == "Failed to delete session: disk said no" @@ -140,13 +136,11 @@ async def test_session_delete_request_returns_to_input_when_picker_becomes_empty monkeypatch.setattr(app, "_switch_to_input_app", switch_to_input) await app.on_session_picker_app_session_delete_requested( - SessionPickerApp.SessionDeleteRequested( - "local:deleted-session", "local", "deleted-session" - ) + SessionPickerApp.SessionDeleteRequested("deleted-session", "deleted-session") ) assert switched_to_input is True - assert picker.removed_option_ids == ["local:deleted-session"] + assert picker.removed_option_ids == ["deleted-session"] assert [ widget._content for widget in mounted_widgets @@ -157,40 +151,6 @@ async def test_session_delete_request_returns_to_input_when_picker_becomes_empty ] -@pytest.mark.asyncio -async def test_session_delete_request_rejects_remote_sessions( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - config = build_test_vibe_config( - session_logging=_enabled_session_config(tmp_path), enable_connectors=False - ) - app = build_test_vibe_app(config=config) - mounted_widgets: list[object] = [] - - async def delete_session( - session_id: str, session_config: SessionLoggingConfig - ) -> None: - pytest.fail("remote sessions should not be deleted") - - async def mount_and_scroll(widget: object, after: object | None = None) -> None: - mounted_widgets.append(widget) - - monkeypatch.setattr("vibe.cli.textual_ui.app.delete_saved_session", delete_session) - monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll) - - await app.on_session_picker_app_session_delete_requested( - SessionPickerApp.SessionDeleteRequested( - "remote:remote-session", "remote", "remote-session" - ) - ) - - assert any( - isinstance(widget, ErrorMessage) - and widget._error == "Deleting remote sessions is not supported." - for widget in mounted_widgets - ) - - @pytest.mark.asyncio async def test_session_delete_request_rejects_current_session( tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -217,12 +177,12 @@ async def test_session_delete_request_rejects_current_session( await app.on_session_picker_app_session_delete_requested( SessionPickerApp.SessionDeleteRequested( - f"local:{app.agent_loop.session_id}", "local", app.agent_loop.session_id + app.agent_loop.session_id, app.agent_loop.session_id ) ) assert deleted_sessions == [] - assert picker.cleared_pending_option_ids == [f"local:{app.agent_loop.session_id}"] + assert picker.cleared_pending_option_ids == [app.agent_loop.session_id] assert any( isinstance(widget, ErrorMessage) and widget._error == "Deleting the current session is not supported." diff --git a/tests/cli/test_startup_update_prompt.py b/tests/cli/test_startup_update_prompt.py index d6b368a..f092c03 100644 --- a/tests/cli/test_startup_update_prompt.py +++ b/tests/cli/test_startup_update_prompt.py @@ -8,9 +8,20 @@ from unittest.mock import patch import pytest from tests.conftest import build_test_vibe_config -from vibe.cli.cli import _maybe_run_startup_update_prompt -from vibe.cli.update_notifier import FileSystemUpdateCacheRepository, UpdateCache -from vibe.setup.update_prompt.update_prompt_dialog import UpdatePromptResult +from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway +from vibe import __version__ +from vibe.cli.cli import _maybe_run_startup_update_prompt, _run_check_upgrade +from vibe.cli.update_notifier import ( + FileSystemUpdateCacheRepository, + Update, + UpdateCache, + UpdateGatewayCause, + UpdateGatewayError, +) +from vibe.setup.update_prompt.update_prompt_dialog import ( + UpdatePromptMode, + UpdatePromptResult, +) class _BrokenRepository: @@ -129,6 +140,76 @@ def test_no_op_when_cache_read_raises_oserror() -> None: mock_ask.assert_not_called() +def test_check_upgrade_fetches_and_prompts_when_update_is_available( + repository: FileSystemUpdateCacheRepository, +) -> None: + notifier = FakeUpdateGateway(update=Update(latest_version="999.0.0")) + + with patch( + "vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE + ) as mock_ask: + _run_check_upgrade(repository, update_notifier=notifier, theme="textual-light") + + mock_ask.assert_called_once_with( + __version__, + "999.0.0", + theme="textual-light", + prompt_mode=UpdatePromptMode.CHECK_UPGRADE, + ) + assert notifier.fetch_update_calls == 1 + + +def test_check_upgrade_cancel_does_not_dismiss_update( + repository: FileSystemUpdateCacheRepository, +) -> None: + config = build_test_vibe_config(enable_update_checks=True) + notifier = FakeUpdateGateway(update=Update(latest_version="999.0.0")) + + with patch( + "vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE + ): + _run_check_upgrade(repository, update_notifier=notifier) + + cache = asyncio.run(repository.get()) + assert cache is not None + assert cache.dismissed_version is None + + with patch( + "vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE + ) as mock_ask: + _maybe_run_startup_update_prompt(config, repository) + + mock_ask.assert_called_once() + + +def test_check_upgrade_prints_up_to_date_when_no_update_exists( + repository: FileSystemUpdateCacheRepository, capsys: pytest.CaptureFixture[str] +) -> None: + notifier = FakeUpdateGateway(update=None) + + with patch("vibe.cli.cli.ask_update_prompt") as mock_ask: + _run_check_upgrade(repository, update_notifier=notifier) + + mock_ask.assert_not_called() + out = capsys.readouterr().out + assert "already up to date" in out + assert __version__ in out + + +def test_check_upgrade_exits_one_when_gateway_errors( + repository: FileSystemUpdateCacheRepository, capsys: pytest.CaptureFixture[str] +) -> None: + notifier = FakeUpdateGateway( + error=UpdateGatewayError(cause=UpdateGatewayCause.REQUEST_FAILED) + ) + + with pytest.raises(SystemExit) as excinfo: + _run_check_upgrade(repository, update_notifier=notifier) + + assert excinfo.value.code == 1 + assert "Update check failed" in capsys.readouterr().out + + def test_continue_marks_version_as_dismissed_and_prevents_reprompt( repository: FileSystemUpdateCacheRepository, ) -> None: diff --git a/tests/cli/test_ui_teleport_availability.py b/tests/cli/test_ui_teleport_availability.py index 4b22c6e..13fcac5 100644 --- a/tests/cli/test_ui_teleport_availability.py +++ b/tests/cli/test_ui_teleport_availability.py @@ -8,10 +8,11 @@ import pytest from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway from tests.conftest import build_test_vibe_app, build_test_vibe_config +from tests.constants import OPENAI_BASE_URL from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig -from vibe.core.types import Backend, LLMMessage, Role +from vibe.core.types import Backend def _chat_plan_gateway(*, prompt_switching_to_pro_plan: bool) -> FakeWhoAmIGateway: @@ -100,46 +101,6 @@ async def test_teleport_command_without_history_sends_early_failure_telemetry( ] -@pytest.mark.asyncio -async def test_teleport_command_in_remote_session_sends_early_failure_telemetry( - telemetry_events: list[dict[str, Any]], -) -> None: - app = build_test_vibe_app( - config=_vibe_code_enabled_config(), - plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=False), - ) - app.agent_loop.messages.append(LLMMessage(role=Role.user, content="hello")) - app.agent_loop.messages.append(LLMMessage(role=Role.assistant, content="hi")) - - async with app.run_test() as pilot: - await _wait_until( - pilot.pause, - lambda: app.commands.get_command_name("/teleport") == "teleport", - ) - - await app._remote_manager.attach(session_id="remote-session", config=app.config) - await app.on_chat_input_container_submitted( - ChatInputContainer.Submitted("/teleport") - ) - await _wait_until( - pilot.pause, lambda: len(_teleport_failed_events(telemetry_events)) == 1 - ) - await app._remote_manager.detach() - - assert _teleport_failed_events(telemetry_events) == [ - { - "event_name": "vibe.teleport_failed", - "properties": { - "stage": "remote_session", - "error_class": "TeleportRemoteSessionError", - "push_required": False, - "nb_session_messages": 2, - "session_id": app.agent_loop.session_id, - }, - } - ] - - @pytest.mark.asyncio async def test_teleport_command_hidden_when_current_key_is_not_eligible() -> None: app = build_test_vibe_app( @@ -215,7 +176,7 @@ async def test_teleport_command_hides_after_switching_to_non_mistral_model( ), ProviderConfig( name="openai", - api_base="https://api.openai.com/v1", + api_base=f"{OPENAI_BASE_URL}/v1", api_key_env_var="OPENAI_API_KEY", backend=Backend.GENERIC, ), diff --git a/tests/cli/textual_ui/test_completion_popup.py b/tests/cli/textual_ui/test_completion_popup.py deleted file mode 100644 index 54e00dc..0000000 --- a/tests/cli/textual_ui/test_completion_popup.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import annotations - -from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup - - -def test_rendered_text_length_uses_terminal_cell_width() -> None: - # "你" and "🙂" both occupy 2 terminal cells in Rich (+2 for separator). - assert CompletionPopup.rendered_text_length("@你", "🙂") == 6 - - -def test_rendered_text_length_keeps_description_separator() -> None: - assert CompletionPopup.rendered_text_length("@abc", "def") == 8 diff --git a/tests/cli/textual_ui/test_diff_rendering.py b/tests/cli/textual_ui/test_diff_rendering.py index 77290bd..fb5405a 100644 --- a/tests/cli/textual_ui/test_diff_rendering.py +++ b/tests/cli/textual_ui/test_diff_rendering.py @@ -22,6 +22,11 @@ def _build( def _render(*args, **kwargs): kwargs.setdefault("dark", True) + args = list(args) + # Tests pass a single start line as an int for readability; the renderer + # expects a list of occurrences. + if len(args) >= 4 and isinstance(args[3], int): + args[3] = [args[3]] return render_edit_diff(*args, **kwargs) @@ -156,6 +161,32 @@ class TestRenderEditDiff: assert any(_plain(w).rstrip().endswith("d") for w in removed) assert any(_plain(w).rstrip().endswith("Z") for w in added) + def test_replace_all_renders_each_occurrence(self) -> None: + widgets = render_edit_diff( + "foo", "bar", "py", [3, 10, 25], ansi=False, dark=True + ) + removed = [w for w in widgets if "diff-removed" in w.classes] + added = [w for w in widgets if "diff-added" in w.classes] + assert len(removed) == 3 + assert len(added) == 3 + + def test_replace_all_uses_each_start_line(self) -> None: + widgets = render_edit_diff( + "foo", "bar", "py", [3, 10, 25], ansi=False, dark=True + ) + joined = "\n".join(_plain(w) for w in widgets) + assert "3" in joined + assert "10" in joined + assert "25" in joined + + def test_replace_all_separates_occurrences_with_gap(self) -> None: + widgets = render_edit_diff("foo", "bar", "py", [3, 10], ansi=False, dark=True) + assert sum("diff-gap" in w.classes for w in widgets) == 1 + + def test_single_occurrence_has_no_gap(self) -> None: + widgets = render_edit_diff("foo", "bar", "py", [3], ansi=False, dark=True) + assert all("diff-gap" not in w.classes for w in widgets) + class TestBorderColors: def test_keys_index_into_widgets(self) -> None: diff --git a/tests/cli/textual_ui/test_message_queue_ui.py b/tests/cli/textual_ui/test_message_queue_ui.py index 142d60b..c58982f 100644 --- a/tests/cli/textual_ui/test_message_queue_ui.py +++ b/tests/cli/textual_ui/test_message_queue_ui.py @@ -39,10 +39,12 @@ async def test_no_queue_header_when_empty(vibe_app: VibeApp) -> None: async def test_bash_submitted_during_running_bash_is_queued(vibe_app: VibeApp) -> None: async with vibe_app.run_test() as pilot: chat_input = vibe_app.query_one(ChatInputContainer) - chat_input.value = "!sleep 0.3" + chat_input.value = "!sleep 1" await pilot.press("enter") - await _wait_until(pilot, lambda: vibe_app._bash_task is not None, timeout=1.0) + assert await _wait_until( + pilot, lambda: vibe_app._bash_task is not None, timeout=2.0 + ) chat_input.value = "!echo queued" await pilot.press("enter") @@ -56,6 +58,15 @@ async def test_bash_submitted_during_running_bash_is_queued(vibe_app: VibeApp) - queued_bashes = [w for w in vibe_app.query(BashOutputMessage) if w._queued] assert len(queued_bashes) == 1 + await pilot.press("ctrl+c") + assert await _wait_until( + pilot, lambda: len(vibe_app._input_queue) == 0, timeout=2.0 + ) + await pilot.press("escape") + assert await _wait_until( + pilot, lambda: vibe_app._bash_task is None, timeout=5.0 + ) + @pytest.mark.asyncio async def test_slash_command_rejected_with_warning_when_busy(vibe_app: VibeApp) -> None: diff --git a/tests/cli/textual_ui/test_quit_confirmation.py b/tests/cli/textual_ui/test_quit_confirmation.py index 15b0d63..46b0a10 100644 --- a/tests/cli/textual_ui/test_quit_confirmation.py +++ b/tests/cli/textual_ui/test_quit_confirmation.py @@ -1,12 +1,13 @@ from __future__ import annotations +import asyncio import time -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from tests.conftest import build_test_vibe_app -from vibe.cli.textual_ui.app import VibeApp +from vibe.cli.textual_ui.app import VibeApp, _run_app_with_cleanup from vibe.cli.textual_ui.quit_manager import QUIT_CONFIRM_DELAY, QuitManager @@ -182,3 +183,69 @@ class TestActionDeleteRightOrQuit: mock_confirm.assert_called_once_with( "Ctrl+D", "1 queued message will be discarded" ) + + +@pytest.mark.asyncio +async def test_shutdown_cleanup_cancels_in_flight_tasks(app: VibeApp) -> None: + async def _pending() -> None: + await asyncio.Event().wait() + + agent_task = asyncio.create_task(_pending()) + bash_task = asyncio.create_task(_pending()) + app._agent_task = agent_task + app._bash_task = bash_task + + await asyncio.wait_for(app.shutdown_cleanup(), timeout=1.0) + + assert agent_task.cancelled() + assert bash_task.cancelled() + + +@pytest.mark.asyncio +async def test_shutdown_disables_future_queue_drains(app: VibeApp) -> None: + app._input_queue.append_prompt("queued") + + await app._begin_shutdown() + + with patch("vibe.cli.textual_ui.message_queue.asyncio.create_task") as create_task: + app._queue.start_drain_if_needed() + + create_task.assert_not_called() + + +@pytest.mark.asyncio +async def test_begin_shutdown_stops_scheduled_loop_runner(app: VibeApp) -> None: + with ( + patch.object(app._queue, "shutdown", new_callable=AsyncMock) as queue_shutdown, + patch.object(app._loop_runner, "stop", new_callable=AsyncMock) as loop_stop, + ): + await app._begin_shutdown() + + queue_shutdown.assert_awaited_once() + loop_stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_shutdown_cleanup_flushes_telemetry(app: VibeApp) -> None: + with patch.object( + app.agent_loop.telemetry_client, "aclose", new_callable=AsyncMock + ) as telemetry_aclose: + await app.shutdown_cleanup() + + telemetry_aclose.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_run_app_with_cleanup_runs_cleanup_when_run_async_raises( + app: VibeApp, +) -> None: + with ( + patch.object( + app, "run_async", new_callable=AsyncMock, side_effect=RuntimeError("boom") + ), + patch.object(app, "shutdown_cleanup", new_callable=AsyncMock) as cleanup, + ): + with pytest.raises(RuntimeError, match="boom"): + await _run_app_with_cleanup(app) + + cleanup.assert_awaited_once() diff --git a/tests/cli/textual_ui/test_remote_session_manager.py b/tests/cli/textual_ui/test_remote_session_manager.py deleted file mode 100644 index 4399e47..0000000 --- a/tests/cli/textual_ui/test_remote_session_manager.py +++ /dev/null @@ -1,286 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from vibe.cli.textual_ui.remote.remote_session_manager import RemoteSessionManager -from vibe.core.tools.builtins.ask_user_question import AskUserQuestionArgs -from vibe.core.types import WaitingForInputEvent - - -@pytest.fixture -def manager() -> RemoteSessionManager: - return RemoteSessionManager() - - -class TestProperties: - def test_is_active_false_by_default(self, manager: RemoteSessionManager) -> None: - assert manager.is_active is False - - def test_is_terminated_false_when_inactive( - self, manager: RemoteSessionManager - ) -> None: - assert manager.is_terminated is False - - def test_is_waiting_for_input_false_when_inactive( - self, manager: RemoteSessionManager - ) -> None: - assert manager.is_waiting_for_input is False - - def test_has_pending_input_false_by_default( - self, manager: RemoteSessionManager - ) -> None: - assert manager.has_pending_input is False - - def test_session_id_none_when_inactive(self, manager: RemoteSessionManager) -> None: - assert manager.session_id is None - - -class TestAttachDetach: - @pytest.mark.asyncio - async def test_attach_activates_manager( - self, manager: RemoteSessionManager - ) -> None: - with patch( - "vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource" - ) as MockSource: - mock_source = MagicMock() - mock_source.session_id = "test-session-id" - MockSource.return_value = mock_source - - config = MagicMock() - await manager.attach(session_id="test-session-id", config=config) - - assert manager.is_active is True - assert manager.session_id == "test-session-id" - - @pytest.mark.asyncio - async def test_detach_cleans_up(self, manager: RemoteSessionManager) -> None: - with patch( - "vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource" - ) as MockSource: - mock_source = AsyncMock() - mock_source.session_id = "test-id" - MockSource.return_value = mock_source - - config = MagicMock() - await manager.attach(session_id="test-id", config=config) - await manager.detach() - - assert manager.is_active is False - assert manager.session_id is None - assert manager.has_pending_input is False - mock_source.close.assert_called_once() - - @pytest.mark.asyncio - async def test_attach_detaches_previous_session( - self, manager: RemoteSessionManager - ) -> None: - with patch( - "vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource" - ) as MockSource: - first_source = AsyncMock() - second_source = MagicMock() - second_source.session_id = "second-id" - MockSource.side_effect = [first_source, second_source] - - config = MagicMock() - await manager.attach(session_id="first-id", config=config) - await manager.attach(session_id="second-id", config=config) - - first_source.close.assert_called_once() - assert manager.session_id == "second-id" - - -class TestValidateInput: - @pytest.mark.asyncio - async def test_returns_none_when_waiting_for_input( - self, manager: RemoteSessionManager - ) -> None: - with patch( - "vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource" - ) as MockSource: - mock_source = MagicMock() - mock_source.is_terminated = False - mock_source.is_waiting_for_input = True - MockSource.return_value = mock_source - - config = MagicMock() - await manager.attach(session_id="id", config=config) - - assert manager.validate_input() is None - - @pytest.mark.asyncio - async def test_returns_warning_when_terminated( - self, manager: RemoteSessionManager - ) -> None: - with patch( - "vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource" - ) as MockSource: - mock_source = MagicMock() - mock_source.is_terminated = True - MockSource.return_value = mock_source - - config = MagicMock() - await manager.attach(session_id="id", config=config) - - result = manager.validate_input() - assert result is not None - assert "ended" in result - - @pytest.mark.asyncio - async def test_returns_warning_when_not_waiting_for_input( - self, manager: RemoteSessionManager - ) -> None: - with patch( - "vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource" - ) as MockSource: - mock_source = MagicMock() - mock_source.is_terminated = False - mock_source.is_waiting_for_input = False - MockSource.return_value = mock_source - - config = MagicMock() - await manager.attach(session_id="id", config=config) - - result = manager.validate_input() - assert result is not None - assert "not waiting" in result - - -class TestSendPrompt: - @pytest.mark.asyncio - async def test_raises_when_inactive_and_required( - self, manager: RemoteSessionManager - ) -> None: - with pytest.raises(RuntimeError, match="No active remote session"): - await manager.send_prompt("hello") - - @pytest.mark.asyncio - async def test_returns_silently_when_inactive_and_not_required( - self, manager: RemoteSessionManager - ) -> None: - await manager.send_prompt("hello", require_source=False) - - @pytest.mark.asyncio - async def test_restores_pending_on_error( - self, manager: RemoteSessionManager - ) -> None: - with patch( - "vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource" - ) as MockSource: - mock_source = AsyncMock() - mock_source.send_prompt.side_effect = Exception("connection error") - MockSource.return_value = mock_source - - config = MagicMock() - await manager.attach(session_id="id", config=config) - - event = WaitingForInputEvent(task_id="t1", label="test") - manager.set_pending_input(event) - - with pytest.raises(Exception, match="connection error"): - await manager.send_prompt("hello") - - assert manager.has_pending_input is True - - -class TestPendingInput: - def test_set_and_cancel_pending_input(self, manager: RemoteSessionManager) -> None: - event = WaitingForInputEvent(task_id="t1", label="test") - manager.set_pending_input(event) - assert manager.has_pending_input is True - - manager.cancel_pending_input() - assert manager.has_pending_input is False - - -class TestBuildQuestionArgs: - def test_returns_none_with_no_predefined_answers( - self, manager: RemoteSessionManager - ) -> None: - event = WaitingForInputEvent(task_id="t1", label="test") - assert manager.build_question_args(event) is None - - def test_returns_none_with_one_predefined_answer( - self, manager: RemoteSessionManager - ) -> None: - event = WaitingForInputEvent( - task_id="t1", label="test", predefined_answers=["only one"] - ) - assert manager.build_question_args(event) is None - - def test_returns_args_with_two_predefined_answers( - self, manager: RemoteSessionManager - ) -> None: - event = WaitingForInputEvent( - task_id="t1", label="Pick one", predefined_answers=["yes", "no"] - ) - result = manager.build_question_args(event) - assert result is not None - assert isinstance(result, AskUserQuestionArgs) - assert len(result.questions) == 1 - assert result.questions[0].question == "Pick one" - assert len(result.questions[0].options) == 2 - - def test_caps_at_four_predefined_answers( - self, manager: RemoteSessionManager - ) -> None: - event = WaitingForInputEvent( - task_id="t1", - label="Pick", - predefined_answers=["a", "b", "c", "d", "e", "f"], - ) - result = manager.build_question_args(event) - assert result is not None - assert len(result.questions[0].options) == 4 - - def test_uses_default_question_when_no_label( - self, manager: RemoteSessionManager - ) -> None: - event = WaitingForInputEvent(task_id="t1", predefined_answers=["a", "b"]) - result = manager.build_question_args(event) - assert result is not None - assert result.questions[0].question == "Choose an answer" - - -class TestBuildTerminalMessage: - def test_completed_when_no_source(self, manager: RemoteSessionManager) -> None: - msg_type, text = manager.build_terminal_message() - assert msg_type == "info" - assert "completed" in text - - @pytest.mark.asyncio - async def test_failed_state(self, manager: RemoteSessionManager) -> None: - with patch( - "vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource" - ) as MockSource: - mock_source = MagicMock() - mock_source.is_failed = True - mock_source.is_canceled = False - MockSource.return_value = mock_source - - config = MagicMock() - await manager.attach(session_id="id", config=config) - - msg_type, text = manager.build_terminal_message() - assert msg_type == "error" - assert "failed" in text.lower() - - @pytest.mark.asyncio - async def test_canceled_state(self, manager: RemoteSessionManager) -> None: - with patch( - "vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource" - ) as MockSource: - mock_source = MagicMock() - mock_source.is_failed = False - mock_source.is_canceled = True - MockSource.return_value = mock_source - - config = MagicMock() - await manager.attach(session_id="id", config=config) - - msg_type, text = manager.build_terminal_message() - assert msg_type == "warning" - assert "canceled" in text.lower() diff --git a/tests/cli/textual_ui/test_session_picker.py b/tests/cli/textual_ui/test_session_picker.py index b898073..41ac8fb 100644 --- a/tests/cli/textual_ui/test_session_picker.py +++ b/tests/cli/textual_ui/test_session_picker.py @@ -19,25 +19,21 @@ def sample_sessions() -> list[ResumeSessionInfo]: return [ ResumeSessionInfo( session_id="session-a", - source="local", cwd="/test", title="Session A", end_time=(datetime.now(UTC) - timedelta(minutes=5)).isoformat(), ), ResumeSessionInfo( session_id="session-b", - source="local", cwd="/test", title="Session B", end_time=(datetime.now(UTC) - timedelta(hours=1)).isoformat(), ), ResumeSessionInfo( session_id="session-c", - source="remote", cwd="/test", title="Session C", end_time=(datetime.now(UTC) - timedelta(days=1)).isoformat(), - status="RUNNING", ), ] @@ -45,9 +41,9 @@ def sample_sessions() -> list[ResumeSessionInfo]: @pytest.fixture def sample_latest_messages() -> dict[str, str]: return { - "local:session-a": "Help me fix this bug", - "local:session-b": "Refactor the authentication module", - "remote:session-c": "Add unit tests for the API", + "session-a": "Help me fix this bug", + "session-b": "Refactor the authentication module", + "session-c": "Add unit tests for the API", } @@ -143,31 +139,19 @@ class TestSessionPickerAppInit: class TestSessionPickerMessages: def test_session_selected_stores_option_id(self) -> None: - msg = SessionPickerApp.SessionSelected( - "local:test-session-id", "local", "test-session-id" - ) - assert msg.option_id == "local:test-session-id" - assert msg.source == "local" + msg = SessionPickerApp.SessionSelected("test-session-id", "test-session-id") + assert msg.option_id == "test-session-id" assert msg.session_id == "test-session-id" - def test_session_selected_with_full_uuid(self) -> None: - session_id = "abc12345-6789-0123-4567-89abcdef0123" - option_id = f"remote:{session_id}" - msg = SessionPickerApp.SessionSelected(option_id, "remote", session_id) - assert msg.option_id == option_id - assert msg.source == "remote" - assert msg.session_id == session_id - def test_cancelled_can_be_instantiated(self) -> None: msg = SessionPickerApp.Cancelled() assert isinstance(msg, SessionPickerApp.Cancelled) def test_session_delete_requested_stores_session_info(self) -> None: msg = SessionPickerApp.SessionDeleteRequested( - "local:test-session-id", "local", "test-session-id" + "test-session-id", "test-session-id" ) - assert msg.option_id == "local:test-session-id" - assert msg.source == "local" + assert msg.option_id == "test-session-id" assert msg.session_id == "test-session-id" @@ -199,15 +183,15 @@ class TestSessionPickerSessionRemoval: picker = SessionPickerApp( sessions=sample_sessions, latest_messages=sample_latest_messages ) - option_list = FakeOptionList(highlighted_option_id="local:session-a") + option_list = FakeOptionList(highlighted_option_id="session-a") posted_messages: list[object] = [] monkeypatch.setattr(picker, "query_one", lambda _selector: option_list) monkeypatch.setattr(picker, "post_message", posted_messages.append) picker.action_request_delete() - assert_delete_state(picker, kind="confirmation", option_id="local:session-a") - assert option_list.replaced_prompts[-1].option_id == "local:session-a" + assert_delete_state(picker, kind="confirmation", option_id="session-a") + assert option_list.replaced_prompts[-1].option_id == "session-a" assert ( "Press D again to delete" in option_list.replaced_prompts[-1].prompt.plain ) @@ -222,7 +206,7 @@ class TestSessionPickerSessionRemoval: picker = SessionPickerApp( sessions=sample_sessions, latest_messages=sample_latest_messages ) - option_list = FakeOptionList(highlighted_option_id="local:session-a") + option_list = FakeOptionList(highlighted_option_id="session-a") posted_messages: list[object] = [] monkeypatch.setattr(picker, "query_one", lambda _selector: option_list) monkeypatch.setattr(picker, "post_message", posted_messages.append) @@ -230,14 +214,13 @@ class TestSessionPickerSessionRemoval: picker.action_request_delete() picker.action_request_delete() - assert_delete_state(picker, kind="pending", option_id="local:session-a") - assert option_list.replaced_prompts[-1].option_id == "local:session-a" + assert_delete_state(picker, kind="pending", option_id="session-a") + assert option_list.replaced_prompts[-1].option_id == "session-a" assert "Deleting..." in option_list.replaced_prompts[-1].prompt.plain assert len(posted_messages) == 1 message = posted_messages[0] assert isinstance(message, SessionPickerApp.SessionDeleteRequested) - assert message.option_id == "local:session-a" - assert message.source == "local" + assert message.option_id == "session-a" assert message.session_id == "session-a" def test_delete_confirmation_is_consumed_after_request( @@ -249,7 +232,7 @@ class TestSessionPickerSessionRemoval: picker = SessionPickerApp( sessions=sample_sessions, latest_messages=sample_latest_messages ) - option_list = FakeOptionList(highlighted_option_id="local:session-a") + option_list = FakeOptionList(highlighted_option_id="session-a") posted_messages: list[object] = [] monkeypatch.setattr(picker, "query_one", lambda _selector: option_list) monkeypatch.setattr(picker, "post_message", posted_messages.append) @@ -259,34 +242,10 @@ class TestSessionPickerSessionRemoval: picker.action_request_delete() assert len(posted_messages) == 1 - assert_delete_state(picker, kind="pending", option_id="local:session-a") - assert option_list.replaced_prompts[-1].option_id == "local:session-a" + assert_delete_state(picker, kind="pending", option_id="session-a") + assert option_list.replaced_prompts[-1].option_id == "session-a" assert "Deleting..." in option_list.replaced_prompts[-1].prompt.plain - def test_delete_request_shows_feedback_for_remote_sessions( - self, - sample_sessions: list[ResumeSessionInfo], - sample_latest_messages: dict[str, str], - monkeypatch: pytest.MonkeyPatch, - ) -> None: - picker = SessionPickerApp( - sessions=sample_sessions, latest_messages=sample_latest_messages - ) - option_list = FakeOptionList(highlighted_option_id="remote:session-c") - posted_messages: list[object] = [] - monkeypatch.setattr(picker, "query_one", lambda _selector: option_list) - monkeypatch.setattr(picker, "post_message", posted_messages.append) - - picker.action_request_delete() - - assert_delete_state(picker, kind="feedback", option_id="remote:session-c") - assert option_list.replaced_prompts[-1].option_id == "remote:session-c" - assert ( - "Can't delete remote session" - in option_list.replaced_prompts[-1].prompt.plain - ) - assert posted_messages == [] - def test_delete_request_shows_feedback_for_current_session( self, sample_sessions: list[ResumeSessionInfo], @@ -298,15 +257,15 @@ class TestSessionPickerSessionRemoval: latest_messages=sample_latest_messages, current_session_id="session-a", ) - option_list = FakeOptionList(highlighted_option_id="local:session-a") + option_list = FakeOptionList(highlighted_option_id="session-a") posted_messages: list[object] = [] monkeypatch.setattr(picker, "query_one", lambda _selector: option_list) monkeypatch.setattr(picker, "post_message", posted_messages.append) picker.action_request_delete() - assert_delete_state(picker, kind="feedback", option_id="local:session-a") - assert option_list.replaced_prompts[-1].option_id == "local:session-a" + assert_delete_state(picker, kind="feedback", option_id="session-a") + assert option_list.replaced_prompts[-1].option_id == "session-a" assert ( "Can't delete current session" in option_list.replaced_prompts[-1].prompt.plain @@ -315,7 +274,7 @@ class TestSessionPickerSessionRemoval: picker.action_request_delete() - assert_delete_state(picker, kind="feedback", option_id="local:session-a") + assert_delete_state(picker, kind="feedback", option_id="session-a") assert posted_messages == [] def test_pending_delete_blocks_resume_selection( @@ -327,7 +286,7 @@ class TestSessionPickerSessionRemoval: picker = SessionPickerApp( sessions=sample_sessions, latest_messages=sample_latest_messages ) - option_list = FakeOptionList(highlighted_option_id="local:session-a") + option_list = FakeOptionList(highlighted_option_id="session-a") posted_messages: list[object] = [] monkeypatch.setattr(picker, "query_one", lambda _selector: option_list) monkeypatch.setattr(picker, "post_message", posted_messages.append) @@ -335,15 +294,15 @@ class TestSessionPickerSessionRemoval: picker.action_request_delete() picker.action_request_delete() picker.on_option_list_option_selected( - cast(OptionList.OptionSelected, FakeOptionEvent("local:session-a")) + cast(OptionList.OptionSelected, FakeOptionEvent("session-a")) ) picker.on_option_list_option_selected( - cast(OptionList.OptionSelected, FakeOptionEvent("local:session-b")) + cast(OptionList.OptionSelected, FakeOptionEvent("session-b")) ) assert len(posted_messages) == 1 assert isinstance(posted_messages[0], SessionPickerApp.SessionDeleteRequested) - assert_delete_state(picker, kind="pending", option_id="local:session-a") + assert_delete_state(picker, kind="pending", option_id="session-a") def test_clear_pending_delete_restores_session_option( self, @@ -354,7 +313,7 @@ class TestSessionPickerSessionRemoval: picker = SessionPickerApp( sessions=sample_sessions, latest_messages=sample_latest_messages ) - option_list = FakeOptionList(highlighted_option_id="local:session-a") + option_list = FakeOptionList(highlighted_option_id="session-a") posted_messages: list[object] = [] monkeypatch.setattr(picker, "query_one", lambda _selector: option_list) monkeypatch.setattr(picker, "post_message", posted_messages.append) @@ -362,9 +321,9 @@ class TestSessionPickerSessionRemoval: picker.action_request_delete() picker.action_request_delete() - assert picker.clear_pending_delete("local:session-a") is True + assert picker.clear_pending_delete("session-a") is True assert picker._delete_state is None - assert option_list.replaced_prompts[-1].option_id == "local:session-a" + assert option_list.replaced_prompts[-1].option_id == "session-a" assert "Help me fix this bug" in option_list.replaced_prompts[-1].prompt.plain def test_highlighting_another_session_clears_confirmation( @@ -376,16 +335,16 @@ class TestSessionPickerSessionRemoval: picker = SessionPickerApp( sessions=sample_sessions, latest_messages=sample_latest_messages ) - option_list = FakeOptionList(highlighted_option_id="local:session-a") + option_list = FakeOptionList(highlighted_option_id="session-a") monkeypatch.setattr(picker, "query_one", lambda _selector: option_list) picker.action_request_delete() picker.on_option_list_option_highlighted( - cast(OptionList.OptionHighlighted, FakeOptionEvent("local:session-b")) + cast(OptionList.OptionHighlighted, FakeOptionEvent("session-b")) ) assert picker._delete_state is None - assert option_list.replaced_prompts[-1].option_id == "local:session-a" + assert option_list.replaced_prompts[-1].option_id == "session-a" assert "Help me fix this bug" in option_list.replaced_prompts[-1].prompt.plain def test_highlighting_another_session_clears_delete_feedback( @@ -395,18 +354,20 @@ class TestSessionPickerSessionRemoval: monkeypatch: pytest.MonkeyPatch, ) -> None: picker = SessionPickerApp( - sessions=sample_sessions, latest_messages=sample_latest_messages + sessions=sample_sessions, + latest_messages=sample_latest_messages, + current_session_id="session-c", ) - option_list = FakeOptionList(highlighted_option_id="remote:session-c") + option_list = FakeOptionList(highlighted_option_id="session-c") monkeypatch.setattr(picker, "query_one", lambda _selector: option_list) picker.action_request_delete() picker.on_option_list_option_highlighted( - cast(OptionList.OptionHighlighted, FakeOptionEvent("local:session-b")) + cast(OptionList.OptionHighlighted, FakeOptionEvent("session-b")) ) assert picker._delete_state is None - assert option_list.replaced_prompts[-1].option_id == "remote:session-c" + assert option_list.replaced_prompts[-1].option_id == "session-c" assert ( "Add unit tests for the API" in option_list.replaced_prompts[-1].prompt.plain @@ -421,7 +382,7 @@ class TestSessionPickerSessionRemoval: picker = SessionPickerApp( sessions=sample_sessions, latest_messages=sample_latest_messages ) - option_list = FakeOptionList(highlighted_option_id="local:session-a") + option_list = FakeOptionList(highlighted_option_id="session-a") posted_messages: list[object] = [] monkeypatch.setattr(picker, "query_one", lambda _selector: option_list) monkeypatch.setattr(picker, "post_message", posted_messages.append) @@ -444,9 +405,11 @@ class TestSessionPickerSessionRemoval: monkeypatch: pytest.MonkeyPatch, ) -> None: picker = SessionPickerApp( - sessions=sample_sessions, latest_messages=sample_latest_messages + sessions=sample_sessions, + latest_messages=sample_latest_messages, + current_session_id="session-c", ) - option_list = FakeOptionList(highlighted_option_id="remote:session-c") + option_list = FakeOptionList(highlighted_option_id="session-c") posted_messages: list[object] = [] monkeypatch.setattr(picker, "query_one", lambda _selector: option_list) monkeypatch.setattr(picker, "post_message", posted_messages.append) @@ -471,20 +434,20 @@ class TestSessionPickerSessionRemoval: picker = SessionPickerApp( sessions=sample_sessions, latest_messages=sample_latest_messages ) - option_list = FakeOptionList(highlighted_option_id="local:session-a") + option_list = FakeOptionList(highlighted_option_id="session-a") monkeypatch.setattr(picker, "query_one", lambda _selector: option_list) picker.action_request_delete() - assert_delete_state(picker, kind="confirmation", option_id="local:session-a") + assert_delete_state(picker, kind="confirmation", option_id="session-a") - assert picker.remove_session("local:session-a") is True + assert picker.remove_session("session-a") is True assert [session.option_id for session in picker._sessions] == [ - "local:session-b", - "remote:session-c", + "session-b", + "session-c", ] - assert "local:session-a" not in picker._latest_messages + assert "session-a" not in picker._latest_messages assert picker._delete_state is None - assert option_list.removed_option_ids == ["local:session-a"] + assert option_list.removed_option_ids == ["session-a"] def test_remove_missing_session_returns_false( self, @@ -498,7 +461,7 @@ class TestSessionPickerSessionRemoval: option_list = FakeOptionList() monkeypatch.setattr(picker, "query_one", lambda _selector: option_list) - assert picker.remove_session("local:missing") is False + assert picker.remove_session("missing") is False assert picker._sessions == sample_sessions assert picker._latest_messages == sample_latest_messages diff --git a/tests/cli/textual_ui/test_user_message_attachments.py b/tests/cli/textual_ui/test_user_message_attachments.py index cb81b3b..25eb615 100644 --- a/tests/cli/textual_ui/test_user_message_attachments.py +++ b/tests/cli/textual_ui/test_user_message_attachments.py @@ -5,12 +5,14 @@ from weakref import WeakKeyDictionary from vibe.cli.textual_ui.widgets.messages import UserMessage from vibe.cli.textual_ui.windowing.history import build_history_widgets -from vibe.core.types import ImageAttachment, LLMMessage, Role +from vibe.core.types import FileImageSource, ImageAttachment, LLMMessage, Role def _att(path: Path, alias: str) -> ImageAttachment: path.write_bytes(b"\x89PNG") - return ImageAttachment(path=path, alias=alias, mime_type="image/png") + return ImageAttachment( + source=FileImageSource(path=path), alias=alias, mime_type="image/png" + ) def test_attachments_footer_singular(tmp_path: Path) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index be3d493..400be8f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,9 @@ from pathlib import Path import sys from typing import Any +import keyring +from keyring.backend import KeyringBackend +import keyring.errors import pytest import tomli_w @@ -34,6 +37,39 @@ from vibe.core.config.harness_files import ( from vibe.core.llm.types import BackendLike +class _EmptyKeyring(KeyringBackend): + """A keyring backend that stores nothing, used to keep tests off the real OS keyring.""" + + priority = 1 # type: ignore[assignment] + + def get_password(self, service: str, username: str) -> str | None: + return None + + def set_password(self, service: str, username: str, password: str) -> None: + return None + + def delete_password(self, service: str, username: str) -> None: + raise keyring.errors.PasswordDeleteError(username) + + +@pytest.fixture(autouse=True) +def _disable_os_keyring() -> Generator[None, None, None]: + """Keep the suite off the real OS keyring. + + ``resolve_api_key`` and ``VibeConfig._check_api_key`` now consult the keyring, so + without this every config construction would touch the real Keychain. We install an + empty backend (rather than patching ``keyring.get_password``) so tests that swap in + their own backend via ``keyring.set_keyring`` still work. Tests that exercise keyring + behaviour opt in by patching ``keyring.get_password`` / ``set_password`` directly. + """ + original = keyring.get_keyring() + keyring.set_keyring(_EmptyKeyring()) + try: + yield + finally: + keyring.set_keyring(original) + + def get_base_config() -> dict[str, Any]: return { "active_model": "devstral-latest", @@ -143,6 +179,8 @@ def _scratchpad_dir( @pytest.fixture(autouse=True) def _mock_api_key(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("MISTRAL_API_KEY", "mock") + monkeypatch.setenv("ANTHROPIC_API_KEY", "mock") + monkeypatch.setenv("OPENAI_API_KEY", "mock") @pytest.fixture(autouse=True) @@ -262,6 +300,9 @@ def build_test_vibe_config(**kwargs) -> VibeConfig: ) if kwargs.get("models"): kwargs.setdefault("active_model", kwargs["models"][0].alias) + # Connectors trigger a real HTTP discovery on agent construction; off by + # default so tests don't pay for it. Connector tests pass enable_connectors=True. + kwargs.setdefault("enable_connectors", False) return VibeConfig( session_logging=resolved_session_logging, enable_update_checks=resolved_enable_update_checks, diff --git a/tests/constants.py b/tests/constants.py new file mode 100644 index 0000000..acafc42 --- /dev/null +++ b/tests/constants.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +MISTRAL_BASE_URL = "https://api.mistral.ai" +CHAT_COMPLETIONS_PATH = "/v1/chat/completions" +CONNECTORS_BOOTSTRAP_PATH = "/v1/connectors/bootstrap" + +ANTHROPIC_BASE_URL = "https://api.anthropic.com" +ANTHROPIC_MESSAGES_PATH = "/v1/messages" + +OPENAI_BASE_URL = "https://api.openai.com" +OPENAI_RESPONSES_PATH = "/v1/responses" + +TELEPORT_SESSIONS_PATH = "/api/v1/code/sessions" +TELEPORT_COMPLETE_URL = "https://chat.example.com/code/project-id/web-session-id" diff --git a/tests/core/nuage/test_remote_events_source.py b/tests/core/nuage/test_remote_events_source.py deleted file mode 100644 index e0897e2..0000000 --- a/tests/core/nuage/test_remote_events_source.py +++ /dev/null @@ -1,301 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock, patch - -from pydantic import BaseModel, ValidationError -import pytest - -from tests.conftest import build_test_vibe_config -from vibe.core.agent_loop import AgentLoopStateError -from vibe.core.nuage.exceptions import ErrorCode, WorkflowsException -from vibe.core.nuage.remote_events_source import RemoteEventsSource -from vibe.core.nuage.streaming import StreamEvent - -_SESSION_ID = "test-session" - - -def _make_source(**kwargs) -> RemoteEventsSource: - config = build_test_vibe_config(enabled_tools=kwargs.pop("enabled_tools", [])) - return RemoteEventsSource(session_id=_SESSION_ID, config=config, **kwargs) - - -def _make_retryable_exc(msg: str) -> WorkflowsException: - return WorkflowsException(message=msg, code=ErrorCode.GET_EVENTS_STREAM_ERROR) - - -def _make_stream_event( - broker_sequence: int | None = None, data: dict | None = None -) -> StreamEvent: - return StreamEvent(data=data or {}, broker_sequence=broker_sequence) - - -class TestIsRetryableStreamDisconnect: - def test_peer_closed_connection(self) -> None: - source = _make_source() - exc = _make_retryable_exc("Peer closed connection without response") - assert source._is_retryable_stream_disconnect(exc) is True - - def test_incomplete_chunked_read(self) -> None: - source = _make_source() - exc = _make_retryable_exc("Incomplete chunked read during streaming") - assert source._is_retryable_stream_disconnect(exc) is True - - def test_non_retryable_message(self) -> None: - source = _make_source() - exc = WorkflowsException( - message="some other error", code=ErrorCode.GET_EVENTS_STREAM_ERROR - ) - assert source._is_retryable_stream_disconnect(exc) is False - - def test_wrong_error_code(self) -> None: - source = _make_source() - exc = WorkflowsException( - message="peer closed connection", - code=ErrorCode.POST_EXECUTIONS_SIGNALS_ERROR, - ) - assert source._is_retryable_stream_disconnect(exc) is False - - -async def _async_gen_from_list(items): - for item in items: - yield item - - -async def _async_gen_raise(items, exc): - for item in items: - yield item - raise exc - - -class _FakeStream: - def __init__(self, payloads=None, exc=None): - self._payloads = payloads or [] - self._exc = exc - self._closed = False - - def __aiter__(self): - return self._iterate().__aiter__() - - async def _iterate(self): - for p in self._payloads: - yield p - if self._exc is not None: - raise self._exc - - async def aclose(self): - self._closed = True - - -class TestStreamRemoteEventsRetry: - @pytest.mark.asyncio - async def test_retries_on_retryable_disconnect(self) -> None: - source = _make_source() - exc = _make_retryable_exc("peer closed connection") - - call_count = 0 - - def make_stream(_params): - nonlocal call_count - call_count += 1 - return _FakeStream(exc=exc) - - mock_client = MagicMock() - mock_client.stream_events = make_stream - source._client = mock_client - - with patch("asyncio.sleep", new_callable=AsyncMock): - events = [e async for e in source._stream_remote_events()] - - assert events == [] - assert call_count == 4 # 1 initial + 3 retries - - @pytest.mark.asyncio - async def test_stops_after_max_retry_count(self) -> None: - source = _make_source() - exc = _make_retryable_exc("incomplete chunked read") - - call_count = 0 - - def make_stream(_params): - nonlocal call_count - call_count += 1 - return _FakeStream(exc=exc) - - mock_client = MagicMock() - mock_client.stream_events = make_stream - source._client = mock_client - - with patch("asyncio.sleep", new_callable=AsyncMock): - events = [e async for e in source._stream_remote_events()] - - assert events == [] - assert call_count == 4 - - @pytest.mark.asyncio - async def test_resets_retry_count_on_successful_event(self) -> None: - source = _make_source() - exc = _make_retryable_exc("peer closed connection") - - successful_event = _make_stream_event(broker_sequence=0, data={}) - call_count = 0 - - def make_stream(_params): - nonlocal call_count - call_count += 1 - if call_count <= 2: - return _FakeStream(payloads=[successful_event], exc=exc) - return _FakeStream(exc=exc) - - mock_client = MagicMock() - mock_client.stream_events = make_stream - source._client = mock_client - - with ( - patch("asyncio.sleep", new_callable=AsyncMock), - patch.object(source, "_normalize_stream_event", return_value=None), - ): - events = [e async for e in source._stream_remote_events()] - - assert events == [] - # call 1: success + exc -> retry_count = 1 - # call 2: success (reset) + exc -> retry_count = 1 - # call 3: exc -> retry_count = 2 - # call 4: exc -> retry_count = 3 - # call 5: exc -> retry_count = 4 > 3 -> break - assert call_count == 5 - - @pytest.mark.asyncio - async def test_non_retryable_raises_agent_loop_state_error(self) -> None: - source = _make_source() - exc = WorkflowsException( - message="something bad", code=ErrorCode.TEMPORAL_CONNECTION_ERROR - ) - - mock_client = MagicMock() - mock_client.stream_events = lambda _: _FakeStream(exc=exc) - source._client = mock_client - - with pytest.raises(AgentLoopStateError): - async for _ in source._stream_remote_events(): - pass - - -class TestStreamRemoteEventsIdleBoundary: - @pytest.mark.asyncio - async def test_stops_on_idle_boundary(self) -> None: - source = _make_source() - event_data = _make_stream_event(broker_sequence=0, data={}) - sentinel_event = MagicMock() - - mock_client = MagicMock() - mock_client.stream_events = lambda _: _FakeStream(payloads=[event_data]) - source._client = mock_client - - workflow_event = MagicMock() - - with ( - patch.object( - source, "_normalize_stream_event", return_value=workflow_event - ), - patch.object( - source, "_consume_workflow_event", return_value=[sentinel_event] - ), - patch.object(source, "_is_idle_boundary", return_value=True) as mock_idle, - ): - events = [ - e - async for e in source._stream_remote_events(stop_on_idle_boundary=True) - ] - - assert events == [sentinel_event] - mock_idle.assert_called_once_with(workflow_event) - - @pytest.mark.asyncio - async def test_continues_past_idle_boundary_when_disabled(self) -> None: - source = _make_source() - event1 = _make_stream_event(broker_sequence=0, data={}) - event2 = _make_stream_event(broker_sequence=1, data={}) - sentinel1 = MagicMock() - sentinel2 = MagicMock() - - mock_client = MagicMock() - mock_client.stream_events = lambda _: _FakeStream(payloads=[event1, event2]) - source._client = mock_client - - workflow_event = MagicMock() - call_count = 0 - - def consume_side_effect(_evt): - nonlocal call_count - call_count += 1 - return [sentinel1] if call_count == 1 else [sentinel2] - - with ( - patch.object( - source, "_normalize_stream_event", return_value=workflow_event - ), - patch.object( - source, "_consume_workflow_event", side_effect=consume_side_effect - ), - patch.object(source, "_is_idle_boundary", return_value=True), - ): - events = [ - e - async for e in source._stream_remote_events(stop_on_idle_boundary=False) - ] - - assert events == [sentinel1, sentinel2] - - -class TestBrokerSequenceTracking: - @pytest.mark.asyncio - async def test_next_start_seq_updated(self) -> None: - source = _make_source() - assert source._next_start_seq == 0 - - event1 = _make_stream_event(broker_sequence=5, data={}) - event2 = _make_stream_event(broker_sequence=10, data={}) - - mock_client = MagicMock() - mock_client.stream_events = lambda _: _FakeStream(payloads=[event1, event2]) - source._client = mock_client - - with patch.object(source, "_normalize_stream_event", return_value=None): - events = [e async for e in source._stream_remote_events()] - - assert events == [] - assert source._next_start_seq == 11 - - @pytest.mark.asyncio - async def test_none_broker_sequence_not_updated(self) -> None: - source = _make_source() - source._next_start_seq = 5 - - event = _make_stream_event(broker_sequence=None, data={}) - - mock_client = MagicMock() - mock_client.stream_events = lambda _: _FakeStream(payloads=[event]) - source._client = mock_client - - with patch.object(source, "_normalize_stream_event", return_value=None): - events = [e async for e in source._stream_remote_events()] - - assert events == [] - assert source._next_start_seq == 5 - - -def test_consume_workflow_event_validation_error_is_logged_and_ignored() -> None: - class _InvalidPayload(BaseModel): - required: str - - source = _make_source() - with pytest.raises(ValidationError) as exc_info: - _InvalidPayload.model_validate({}) - - source._translator.consume_workflow_event = MagicMock(side_effect=exc_info.value) - - with patch("vibe.core.nuage.remote_events_source.logger.warning") as mock_warning: - events = source._consume_workflow_event(MagicMock()) - - assert events == [] - mock_warning.assert_called_once() diff --git a/tests/core/nuage/test_workflows_client.py b/tests/core/nuage/test_workflows_client.py deleted file mode 100644 index 3796f5a..0000000 --- a/tests/core/nuage/test_workflows_client.py +++ /dev/null @@ -1,248 +0,0 @@ -from __future__ import annotations - -import json -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from vibe.core.nuage.client import WorkflowsClient -from vibe.core.nuage.exceptions import ErrorCode, WorkflowsException -from vibe.core.nuage.streaming import StreamEvent, StreamEventsQueryParams -from vibe.core.nuage.workflow import WorkflowExecutionStatus - - -def _make_client() -> WorkflowsClient: - return WorkflowsClient(base_url="http://localhost:8080", api_key="test-key") - - -def _valid_event_payload() -> dict: - return { - "stream": "test-stream", - "timestamp_unix_nano": 1000000, - "data": {"key": "value"}, - } - - -class TestParseSSEData: - def test_valid_json_returns_stream_event(self) -> None: - client = _make_client() - payload = _valid_event_payload() - result = client._parse_sse_data(json.dumps(payload), event_type=None) - assert isinstance(result, StreamEvent) - assert result.stream == "test-stream" - assert result.data == {"key": "value"} - - def test_error_event_type_raises(self) -> None: - client = _make_client() - payload = {"some": "data"} - with pytest.raises(WorkflowsException) as exc_info: - client._parse_sse_data(json.dumps(payload), event_type="error") - assert exc_info.value.code == ErrorCode.GET_EVENTS_STREAM_ERROR - assert "Stream error from server" in exc_info.value.message - - def test_error_key_in_json_raises(self) -> None: - client = _make_client() - payload = {"error": "something went wrong"} - with pytest.raises(WorkflowsException) as exc_info: - client._parse_sse_data(json.dumps(payload), event_type=None) - assert exc_info.value.code == ErrorCode.GET_EVENTS_STREAM_ERROR - assert "something went wrong" in exc_info.value.message - - def test_error_event_type_with_non_dict_parsed(self) -> None: - client = _make_client() - with pytest.raises(WorkflowsException) as exc_info: - client._parse_sse_data(json.dumps("a plain string"), event_type="error") - assert "a plain string" in exc_info.value.message - - def test_malformed_json_raises(self) -> None: - client = _make_client() - with pytest.raises(json.JSONDecodeError): - client._parse_sse_data("{not valid json", event_type=None) - - -class TestIterSSEEvents: - @pytest.mark.asyncio - async def test_parses_data_lines(self) -> None: - client = _make_client() - payload = _valid_event_payload() - lines = [f"data: {json.dumps(payload)}"] - response = AsyncMock() - response.aiter_bytes = _async_byte_iter(lines) - - events = [e async for e in client._iter_sse_events(response)] - assert len(events) == 1 - assert events[0].stream == "test-stream" - - @pytest.mark.asyncio - async def test_skips_empty_lines_and_comments(self) -> None: - client = _make_client() - payload = _valid_event_payload() - lines = ["", ": this is a comment", f"data: {json.dumps(payload)}", ""] - response = AsyncMock() - response.aiter_bytes = _async_byte_iter(lines) - - events = [e async for e in client._iter_sse_events(response)] - assert len(events) == 1 - - @pytest.mark.asyncio - async def test_parses_event_type_and_passes_to_parse(self) -> None: - client = _make_client() - payload = {"error": "server broke"} - lines = ["event: error", f"data: {json.dumps(payload)}"] - response = AsyncMock() - response.aiter_bytes = _async_byte_iter(lines) - - with pytest.raises(WorkflowsException) as exc_info: - _ = [e async for e in client._iter_sse_events(response)] - assert exc_info.value.code == ErrorCode.GET_EVENTS_STREAM_ERROR - - @pytest.mark.asyncio - async def test_resets_event_type_after_data_line(self) -> None: - client = _make_client() - payload = _valid_event_payload() - lines = [ - "event: custom_type", - f"data: {json.dumps(payload)}", - f"data: {json.dumps(payload)}", - ] - response = AsyncMock() - response.aiter_bytes = _async_byte_iter(lines) - - with patch.object( - client, "_parse_sse_data", wraps=client._parse_sse_data - ) as mock_parse: - events = [e async for e in client._iter_sse_events(response)] - assert len(events) == 2 - assert mock_parse.call_args_list[0].args[1] == "custom_type" - assert mock_parse.call_args_list[1].args[1] is None - - @pytest.mark.asyncio - async def test_skips_non_data_non_event_lines(self) -> None: - client = _make_client() - payload = _valid_event_payload() - lines = ["id: 123", "retry: 5000", f"data: {json.dumps(payload)}"] - response = AsyncMock() - response.aiter_bytes = _async_byte_iter(lines) - - events = [e async for e in client._iter_sse_events(response)] - assert len(events) == 1 - - @pytest.mark.asyncio - async def test_parse_failure_logs_warning_and_continues(self) -> None: - client = _make_client() - payload = _valid_event_payload() - lines = ["data: {not valid json}", f"data: {json.dumps(payload)}"] - response = AsyncMock() - response.aiter_bytes = _async_byte_iter(lines) - - with patch("vibe.core.nuage.client.logger") as mock_logger: - events = [e async for e in client._iter_sse_events(response)] - assert len(events) == 1 - mock_logger.warning.assert_called_once() - - -def _setup_mock_client(client: WorkflowsClient, mock_response: AsyncMock) -> None: - mock_stream = AsyncMock() - mock_stream.__aenter__ = AsyncMock(return_value=mock_response) - mock_stream.__aexit__ = AsyncMock(return_value=False) - mock_http = AsyncMock() - mock_http.stream = lambda *args, **kwargs: mock_stream - client._client = mock_http - - -class TestStreamEvents: - @pytest.mark.asyncio - async def test_yields_stream_events(self) -> None: - client = _make_client() - payload = _valid_event_payload() - lines = [f"data: {json.dumps(payload)}"] - - mock_response = AsyncMock() - mock_response.raise_for_status = lambda: None - mock_response.aiter_bytes = _async_byte_iter(lines) - _setup_mock_client(client, mock_response) - - params = StreamEventsQueryParams(workflow_exec_id="wf-1", start_seq=0) - events = [e async for e in client.stream_events(params)] - assert len(events) == 1 - assert isinstance(events[0], StreamEvent) - - @pytest.mark.asyncio - async def test_reraises_workflows_exception(self) -> None: - client = _make_client() - - mock_response = AsyncMock() - mock_response.raise_for_status = lambda: None - mock_response.aiter_bytes = _async_byte_iter([ - "event: error", - 'data: {"error": "stream error"}', - ]) - _setup_mock_client(client, mock_response) - - params = StreamEventsQueryParams(workflow_exec_id="wf-1") - with pytest.raises(WorkflowsException) as exc_info: - _ = [e async for e in client.stream_events(params)] - assert exc_info.value.code == ErrorCode.GET_EVENTS_STREAM_ERROR - - @pytest.mark.asyncio - async def test_wraps_other_exceptions_in_workflows_exception(self) -> None: - client = _make_client() - - mock_response = AsyncMock() - mock_response.raise_for_status.side_effect = RuntimeError("connection lost") - _setup_mock_client(client, mock_response) - - params = StreamEventsQueryParams(workflow_exec_id="wf-1") - with pytest.raises(WorkflowsException) as exc_info: - _ = [e async for e in client.stream_events(params)] - assert exc_info.value.code == ErrorCode.GET_EVENTS_STREAM_ERROR - assert "Failed to stream events" in exc_info.value.message - - -class TestGetWorkflowRuns: - @pytest.mark.asyncio - async def test_sends_current_user_filter_by_default(self) -> None: - client = _make_client() - mock_response = MagicMock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"executions": [], "next_page_token": None} - - mock_http = AsyncMock() - mock_http.get.return_value = mock_response - client._client = mock_http - - await client.get_workflow_runs( - workflow_identifier="workflow-1", - page_size=10, - status=[WorkflowExecutionStatus.RUNNING], - ) - - mock_http.get.assert_awaited_once() - call_params = mock_http.get.call_args.kwargs["params"] - assert call_params["user_id"] == "current" - assert call_params["workflow_identifier"] == "workflow-1" - assert call_params["page_size"] == 10 - - @pytest.mark.asyncio - async def test_allows_overriding_user_id(self) -> None: - client = _make_client() - mock_response = MagicMock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"executions": [], "next_page_token": None} - - mock_http = AsyncMock() - mock_http.get.return_value = mock_response - client._client = mock_http - - await client.get_workflow_runs(user_id="user-123") - - call_params = mock_http.get.call_args.kwargs["params"] - assert call_params["user_id"] == "user-123" - - -def _async_byte_iter(lines: list[str]): - async def _iter(): - for line in lines: - yield f"{line}\n".encode() - - return _iter diff --git a/tests/core/session/test_image_snapshot.py b/tests/core/session/test_image_snapshot.py index 4e1dc51..36867cc 100644 --- a/tests/core/session/test_image_snapshot.py +++ b/tests/core/session/test_image_snapshot.py @@ -1,11 +1,17 @@ from __future__ import annotations +import base64 import hashlib from pathlib import Path import pytest -from vibe.core.session.image_snapshot import ImageSnapshotError, snapshot_image +from vibe.core.session.image_snapshot import ( + ImageSnapshotError, + snapshot_image, + snapshot_image_bytes, +) +from vibe.core.types import MAX_IMAGE_BYTES, FileImageSource, InlineImageSource PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 @@ -19,10 +25,11 @@ def test_snapshot_image_copies_to_attachments_dir(tmp_path: Path) -> None: att = snapshot_image(src, alias="screenshot.png", session_dir=session_dir) digest = hashlib.sha1(PNG_BYTES, usedforsecurity=False).hexdigest() - assert att.path == (session_dir / "attachments" / f"{digest}.png").resolve() + assert isinstance(att.source, FileImageSource) + assert att.source.path == (session_dir / "attachments" / f"{digest}.png").resolve() assert att.alias == "screenshot.png" assert att.mime_type == "image/png" - assert att.path.read_bytes() == PNG_BYTES + assert att.source.path.read_bytes() == PNG_BYTES def test_snapshot_image_is_idempotent_on_same_bytes(tmp_path: Path) -> None: @@ -35,7 +42,9 @@ def test_snapshot_image_is_idempotent_on_same_bytes(tmp_path: Path) -> None: att_a = snapshot_image(src_a, alias="a.png", session_dir=session_dir) att_b = snapshot_image(src_b, alias="b.png", session_dir=session_dir) - assert att_a.path == att_b.path + assert isinstance(att_a.source, FileImageSource) + assert isinstance(att_b.source, FileImageSource) + assert att_a.source.path == att_b.source.path assert sum(1 for _ in (session_dir / "attachments").iterdir()) == 1 @@ -45,7 +54,8 @@ def test_snapshot_image_returns_source_when_session_dir_is_none(tmp_path: Path) att = snapshot_image(src, alias="screenshot.png", session_dir=None) - assert att.path == src.resolve() + assert isinstance(att.source, FileImageSource) + assert att.source.path == src.resolve() assert att.alias == "screenshot.png" @@ -69,3 +79,54 @@ def test_snapshot_image_normalizes_jpg_to_jpeg_mime(tmp_path: Path) -> None: att = snapshot_image(src, alias="photo.jpg", session_dir=None) assert att.mime_type == "image/jpeg" + + +def test_snapshot_image_bytes_inlines_when_session_dir_is_none() -> None: + att = snapshot_image_bytes( + PNG_BYTES, alias="pasted.png", mime_type="image/png", session_dir=None + ) + + assert isinstance(att.source, InlineImageSource) + assert att.source.data == base64.b64encode(PNG_BYTES).decode("ascii") + assert att.alias == "pasted.png" + assert att.mime_type == "image/png" + + +def test_snapshot_image_bytes_writes_file_when_session_dir_is_set( + tmp_path: Path, +) -> None: + session_dir = tmp_path / "session" + + att = snapshot_image_bytes( + PNG_BYTES, alias="pasted.png", mime_type="image/png", session_dir=session_dir + ) + + digest = hashlib.sha1(PNG_BYTES, usedforsecurity=False).hexdigest() + assert isinstance(att.source, FileImageSource) + assert att.source.path == (session_dir / "attachments" / f"{digest}.png").resolve() + assert att.source.path.read_bytes() == PNG_BYTES + + +def test_snapshot_image_bytes_rejects_unsupported_mime() -> None: + with pytest.raises(ImageSnapshotError): + snapshot_image_bytes( + PNG_BYTES, alias="x", mime_type="image/tiff", session_dir=None + ) + + +def test_snapshot_image_rejects_oversized_file(tmp_path: Path) -> None: + src = tmp_path / "big.png" + src.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * MAX_IMAGE_BYTES) + + with pytest.raises(ImageSnapshotError): + snapshot_image(src, alias="big.png", session_dir=None) + + +def test_snapshot_image_bytes_rejects_oversized_data() -> None: + with pytest.raises(ImageSnapshotError): + snapshot_image_bytes( + b"x" * (MAX_IMAGE_BYTES + 1), + alias="big.png", + mime_type="image/png", + session_dir=None, + ) diff --git a/tests/core/test_agent_loop_refresh_config.py b/tests/core/test_agent_loop_refresh_config.py new file mode 100644 index 0000000..c65ea84 --- /dev/null +++ b/tests/core/test_agent_loop_refresh_config.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import pytest + +from tests.conftest import build_test_agent_loop, build_test_vibe_config +from tests.stubs.fake_mcp_registry import FakeMCPRegistry +from vibe.core.config import MCPHttp, MCPOAuth, MCPStreamableHttp, VibeConfig +from vibe.core.tools.mcp import AuthStatus + + +def test_refresh_config_reconciles_mcp_registry_status( + monkeypatch: pytest.MonkeyPatch, +) -> None: + kept = MCPHttp(name="kept", transport="http", url="http://kept:1") + removed = MCPHttp(name="removed", transport="http", url="http://removed:1") + registry = FakeMCPRegistry() + agent_loop = build_test_agent_loop( + config=build_test_vibe_config(mcp_servers=[kept, removed]), + mcp_registry=registry, + ) + refreshed_config = build_test_vibe_config(mcp_servers=[kept]) + + monkeypatch.setattr(VibeConfig, "load", staticmethod(lambda: refreshed_config)) + agent_loop.refresh_config() + + assert registry.status() == {"kept": AuthStatus.STATIC} + + +def test_refresh_config_does_not_mark_undiscovered_oauth_server_ok( + monkeypatch: pytest.MonkeyPatch, +) -> None: + oauth = MCPStreamableHttp( + name="linear", + transport="streamable-http", + url="https://mcp.example.com/mcp", + auth=MCPOAuth(type="oauth", scopes=["read"]), + ) + registry = FakeMCPRegistry() + agent_loop = build_test_agent_loop( + config=build_test_vibe_config(mcp_servers=[]), mcp_registry=registry + ) + refreshed_config = build_test_vibe_config(mcp_servers=[oauth]) + + monkeypatch.setattr(VibeConfig, "load", staticmethod(lambda: refreshed_config)) + agent_loop.refresh_config() + + assert registry.status() == {"linear": AuthStatus.NEEDS_AUTH} diff --git a/tests/core/test_backend_error.py b/tests/core/test_backend_error.py index c25d8c1..96e4c44 100644 --- a/tests/core/test_backend_error.py +++ b/tests/core/test_backend_error.py @@ -2,6 +2,7 @@ from __future__ import annotations import pytest +from tests.constants import CHAT_COMPLETIONS_PATH from vibe.core.llm.exceptions import BackendError, PayloadSummary @@ -24,7 +25,7 @@ def _make_error( ) -> BackendError: return BackendError( provider="test-provider", - endpoint="/v1/chat/completions", + endpoint=CHAT_COMPLETIONS_PATH, status=status, reason="some reason", headers=headers or {}, diff --git a/tests/core/test_build_attachment_counts.py b/tests/core/test_build_attachment_counts.py index dc6093f..4f4a628 100644 --- a/tests/core/test_build_attachment_counts.py +++ b/tests/core/test_build_attachment_counts.py @@ -4,7 +4,7 @@ from pathlib import Path from vibe.core.telemetry.build_metadata import build_attachment_counts from vibe.core.telemetry.types import AttachmentKind -from vibe.core.types import ImageAttachment, LLMMessage, Role +from vibe.core.types import FileImageSource, ImageAttachment, LLMMessage, Role def _msg(images: list[ImageAttachment] | None) -> LLMMessage: @@ -13,7 +13,9 @@ def _msg(images: list[ImageAttachment] | None) -> LLMMessage: def _image() -> ImageAttachment: return ImageAttachment( - path=Path("/tmp/x.png"), alias="x.png", mime_type="image/png" + source=FileImageSource(path=Path("/tmp/x.png")), + alias="x.png", + mime_type="image/png", ) diff --git a/tests/core/test_cache_store.py b/tests/core/test_cache_store.py new file mode 100644 index 0000000..1a2b29f --- /dev/null +++ b/tests/core/test_cache_store.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from pathlib import Path +import tomllib + +from vibe.core.cache_store import FileSystemVibeCodeCacheStore + + +class TestFileSystemVibeCodeCacheStore: + def test_reads_valid_toml_section(self, tmp_path: Path) -> None: + cache_path = tmp_path / "cache.toml" + cache_path.write_text('[update_cache]\nlatest_version = "1.0.0"\n') + store = FileSystemVibeCodeCacheStore(cache_path) + + result = store.read_section("update_cache") + + assert result["latest_version"] == "1.0.0" + + def test_returns_empty_dict_when_file_is_missing(self, tmp_path: Path) -> None: + store = FileSystemVibeCodeCacheStore(tmp_path / "missing.toml") + + assert store.read_section("update_cache") == {} + + def test_returns_empty_dict_when_file_is_corrupted(self, tmp_path: Path) -> None: + cache_path = tmp_path / "cache.toml" + cache_path.write_text("{bad toml") + store = FileSystemVibeCodeCacheStore(cache_path) + + assert store.read_section("update_cache") == {} + + def test_writes_new_file(self, tmp_path: Path) -> None: + cache_path = tmp_path / "cache.toml" + store = FileSystemVibeCodeCacheStore(cache_path) + + store.write_section("feedback", {"last_shown_at": 100.0}) + + with cache_path.open("rb") as f: + data = tomllib.load(f) + assert data["feedback"]["last_shown_at"] == 100.0 + + def test_merges_with_existing(self, tmp_path: Path) -> None: + cache_path = tmp_path / "cache.toml" + cache_path.write_text('[update_cache]\nlatest_version = "1.0.0"\n') + store = FileSystemVibeCodeCacheStore(cache_path) + + store.write_section("feedback", {"last_shown_at": 200.0}) + + with cache_path.open("rb") as f: + data = tomllib.load(f) + assert data["update_cache"]["latest_version"] == "1.0.0" + assert data["feedback"]["last_shown_at"] == 200.0 + + def test_merges_within_section_and_leaves_other_sections_alone( + self, tmp_path: Path + ) -> None: + cache_path = tmp_path / "cache.toml" + cache_path.write_text( + "[update_cache]\n" + 'latest_version = "1.0.0"\n' + "stored_at_timestamp = 1\n" + 'seen_whats_new_version = "1.0.0"\n\n' + "[feedback]\n" + "last_shown_at = 100.0\n" + ) + store = FileSystemVibeCodeCacheStore(cache_path) + + store.write_section( + "update_cache", {"latest_version": "2.0.0", "stored_at_timestamp": 2} + ) + + with cache_path.open("rb") as f: + data = tomllib.load(f) + assert data["update_cache"]["latest_version"] == "2.0.0" + assert data["update_cache"]["stored_at_timestamp"] == 2 + assert data["update_cache"]["seen_whats_new_version"] == "1.0.0" + assert data["feedback"]["last_shown_at"] == 100.0 + + def test_reads_section(self, tmp_path: Path) -> None: + cache_path = tmp_path / "cache.toml" + cache_path.write_text("[feedback]\nlast_shown_at = 100\n") + store = FileSystemVibeCodeCacheStore(cache_path) + + assert store.read_section("feedback") == {"last_shown_at": 100} + + def test_returns_empty_dict_for_missing_section(self, tmp_path: Path) -> None: + store = FileSystemVibeCodeCacheStore(tmp_path / "cache.toml") + + assert store.read_section("feedback") == {} + + def test_writes_section(self, tmp_path: Path) -> None: + cache_path = tmp_path / "cache.toml" + store = FileSystemVibeCodeCacheStore(cache_path) + + store.write_section("feedback", {"last_shown_at": 100}) + + with cache_path.open("rb") as f: + data = tomllib.load(f) + assert data["feedback"]["last_shown_at"] == 100 + + def test_replaces_non_table_section_when_writing(self, tmp_path: Path) -> None: + cache_path = tmp_path / "cache.toml" + cache_path.write_text('feedback = "not-a-table"\n') + store = FileSystemVibeCodeCacheStore(cache_path) + + store.write_section("feedback", {"last_shown_at": 100}) + + with cache_path.open("rb") as f: + data = tomllib.load(f) + assert data["feedback"]["last_shown_at"] == 100 diff --git a/tests/core/test_config_builder.py b/tests/core/test_config_builder.py index 3b71571..7927632 100644 --- a/tests/core/test_config_builder.py +++ b/tests/core/test_config_builder.py @@ -31,6 +31,9 @@ class FakeLayer(ConfigLayer[RawConfig]): async def _build_config_snapshot(self) -> LayerConfigSnapshot: return LayerConfigSnapshot(data=dict(self._data), fingerprint="fp") + async def _save_to_store(self, _next_config: RawConfig) -> str: + raise NotImplementedError + class UntrustedFakeLayer(FakeLayer): async def _check_trust(self) -> bool: diff --git a/tests/core/test_config_layer.py b/tests/core/test_config_layer.py index 97e9c92..8d01921 100644 --- a/tests/core/test_config_layer.py +++ b/tests/core/test_config_layer.py @@ -3,18 +3,24 @@ from __future__ import annotations import asyncio from typing import Any +from jsonpointer import JsonPointerException from pydantic import BaseModel, ValidationError import pytest from vibe.core.config.layer import ( ConfigLayer, + ConfigPatchApplicationError, LayerImplementationError, RawConfig, TrustNotResolvedError, UntrustedLayerError, ) -from vibe.core.config.patch import ConfigPatch -from vibe.core.config.types import ConcurrencyConflictError, LayerConfigSnapshot +from vibe.core.config.patch import AddOperationPatch, ConfigPatch, ReplaceOperationPatch +from vibe.core.config.types import ( + ConcurrencyConflictError, + ConflictStrategy, + LayerConfigSnapshot, +) class StubLayer(ConfigLayer[BaseModel]): @@ -45,6 +51,9 @@ class StubLayer(ConfigLayer[BaseModel]): data=dict(self._data), fingerprint=f"fp-{self.read_count}" ) + async def _save_to_store(self, _next_config: BaseModel) -> str: + raise NotImplementedError("StubLayer.apply() is not implemented") + class ObservableStubLayer(StubLayer): """Stub that records _on_trust_changed calls.""" @@ -57,6 +66,18 @@ class ObservableStubLayer(StubLayer): self.trust_changes.append((old, new)) +class WritableStubLayer(StubLayer): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.writes: list[dict[str, Any]] = [] + + async def _save_to_store(self, _next_config: BaseModel) -> str: + next_data = _next_config.model_dump() + self.writes.append(next_data) + self._data = next_data + return f"write-fp-{len(self.writes)}" + + class SampleSchema(BaseModel): name: str count: int = 0 @@ -91,6 +112,9 @@ async def test_default_check_trust_returns_false() -> None: async def _build_config_snapshot(self) -> LayerConfigSnapshot: return LayerConfigSnapshot(data={}, fingerprint="fp") + async def _save_to_store(self, _next_config: BaseModel) -> str: + raise NotImplementedError + layer = DefaultTrustLayer(name="default") result = await layer.resolve_trust() assert result is False @@ -236,7 +260,7 @@ async def test_load_returns_data() -> None: layer = StubLayer(data={"key": "value"}) result = await layer.load() assert isinstance(result, RawConfig) - assert result.model_extra == {"key": "value"} + assert result.model_dump() == {"key": "value"} assert layer.fingerprint == "fp-1" @@ -246,7 +270,7 @@ async def test_load_auto_resolves_trust() -> None: assert layer.is_trusted is None result = await layer.load() assert layer.is_trusted is True - assert result.model_extra == {"a": 1} + assert result.model_dump() == {"a": 1} assert layer.fingerprint == "fp-1" await layer.resolve_trust() @@ -311,21 +335,21 @@ async def test_invalidate_cache_causes_reload() -> None: async def test_revoke_grant_cycle_refreshes_data() -> None: layer = StubLayer(data={"v": 1}) result1 = await layer.load() - assert result1.model_extra == {"v": 1} + assert result1.model_dump() == {"v": 1} await layer.revoke_trust() layer._data = {"v": 2} await layer.grant_trust() result2 = await layer.load() - assert result2.model_extra == {"v": 2} + assert result2.model_dump() == {"v": 2} @pytest.mark.asyncio async def test_resolve_trust_clears_data_on_revocation() -> None: layer = StubLayer(data={"v": 1}) result1 = await layer.load() - assert result1.model_extra == {"v": 1} + assert result1.model_dump() == {"v": 1} # External revocation via resolve_trust (not revoke_trust) layer._stub_trusted = False @@ -339,7 +363,7 @@ async def test_resolve_trust_clears_data_on_revocation() -> None: await layer.resolve_trust() result2 = await layer.load() - assert result2.model_extra == {"v": 2} + assert result2.model_dump() == {"v": 2} @pytest.mark.asyncio @@ -350,7 +374,7 @@ async def test_load_returns_deep_copy() -> None: result1.model_extra["items"].append("mutated") result2 = await layer.load() - assert result2.model_extra == {"items": ["a", "b"]} + assert result2.model_dump() == {"items": ["a", "b"]} assert layer.read_count == 1 @@ -384,7 +408,7 @@ async def test_default_schema_preserves_extras() -> None: layer = StubLayer(data={"anything": "goes"}) result = await layer.load() assert isinstance(result, RawConfig) - assert result.model_extra == {"anything": "goes"} + assert result.model_dump() == {"anything": "goes"} @pytest.mark.asyncio @@ -423,6 +447,9 @@ async def test_concurrent_loads_serialize() -> None: data={"v": self.read_count}, fingerprint=f"fp-{self.read_count}" ) + async def _save_to_store(self, _next_config: BaseModel) -> str: + raise NotImplementedError + layer = SlowLayer() results = await asyncio.gather(layer.load(), layer.load(), layer.load()) assert layer.read_count == 1 @@ -438,8 +465,155 @@ async def test_fingerprint_returns_none_before_load() -> None: @pytest.mark.asyncio async def test_apply_not_implemented() -> None: layer = StubLayer() + await layer.load() + fingerprint = layer.fingerprint + assert isinstance(fingerprint, str) + with pytest.raises(NotImplementedError): - await layer.apply(ConfigPatch(fingerprint="fp-1")) + await layer.apply(ConfigPatch(fingerprint=fingerprint)) + + +@pytest.mark.asyncio +async def test_apply_operation_failure_wrapped() -> None: + original_data = {"tools": {"disabled_tools": ["bash"]}} + layer = WritableStubLayer(data=original_data) + await layer.load() + fingerprint = layer.fingerprint + assert isinstance(fingerprint, str) + + with pytest.raises( + ConfigPatchApplicationError, match="Layer 'stub': failed to apply patch" + ) as exc_info: + await layer.apply( + ConfigPatch( + AddOperationPatch(path="/tools/enabled_tools/-", value="read"), + fingerprint=fingerprint, + ) + ) + + assert exc_info.value.layer_name == "stub" + assert isinstance(exc_info.value.__cause__, JsonPointerException) + assert "enabled_tools" in str(exc_info.value.__cause__) + assert layer.fingerprint == fingerprint + assert (await layer.load()).model_dump() == original_data + assert layer.writes == [] + + +@pytest.mark.asyncio +async def test_apply_operation_failure_after_successful_operation_is_atomic() -> None: + original_data = {"active_model": "old", "tools": {"disabled_tools": ["bash"]}} + layer = WritableStubLayer(data=original_data) + await layer.load() + fingerprint = layer.fingerprint + assert isinstance(fingerprint, str) + + with pytest.raises(ConfigPatchApplicationError): + await layer.apply( + ConfigPatch( + ReplaceOperationPatch(path="/active_model", value="new"), + AddOperationPatch(path="/tools/enabled_tools/-", value="read"), + fingerprint=fingerprint, + ) + ) + + assert layer.fingerprint == fingerprint + assert (await layer.load()).model_dump() == original_data + assert layer.writes == [] + + +@pytest.mark.asyncio +async def test_apply_schema_validation_failure_wrapped() -> None: + original_data = {"name": "test", "count": 1} + layer = WritableStubLayer(output_schema=SampleSchema, data=original_data) + await layer.load() + fingerprint = layer.fingerprint + assert isinstance(fingerprint, str) + + with pytest.raises( + ConfigPatchApplicationError, match="Layer 'stub': failed to apply patch" + ) as exc_info: + await layer.apply( + ConfigPatch( + ReplaceOperationPatch(path="/count", value={"bad": "value"}), + fingerprint=fingerprint, + ) + ) + + assert exc_info.value.layer_name == "stub" + assert isinstance(exc_info.value.__cause__, ValidationError) + assert layer.fingerprint == fingerprint + assert (await layer.load()).model_dump() == original_data + assert layer.writes == [] + + +@pytest.mark.asyncio +async def test_apply_cancel_rejects_stale_fingerprint() -> None: + layer = WritableStubLayer(data={"key": "old"}) + await layer.load() + fingerprint = layer.fingerprint + assert isinstance(fingerprint, str) + + await layer.apply( + ConfigPatch( + ReplaceOperationPatch(path="/key", value="first"), fingerprint=fingerprint + ) + ) + + with pytest.raises(ConcurrencyConflictError) as exc_info: + await layer.apply( + ConfigPatch( + ReplaceOperationPatch(path="/key", value="second"), + fingerprint=fingerprint, + ) + ) + + assert exc_info.value.expected_fp == fingerprint + assert exc_info.value.actual_fp == layer.fingerprint + assert (await layer.load()).model_dump() == {"key": "first"} + + +@pytest.mark.asyncio +async def test_apply_replace_accepts_stale_fingerprint() -> None: + layer = WritableStubLayer(data={"key": "old"}) + await layer.load() + fingerprint = layer.fingerprint + assert isinstance(fingerprint, str) + + await layer.apply( + ConfigPatch( + ReplaceOperationPatch(path="/key", value="first"), fingerprint=fingerprint + ) + ) + await layer.apply( + ConfigPatch( + ReplaceOperationPatch(path="/key", value="second"), fingerprint=fingerprint + ), + on_conflict=ConflictStrategy.REPLACE, + ) + + assert (await layer.load()).model_dump() == {"key": "second"} + assert layer.writes == [{"key": "first"}, {"key": "second"}] + + +@pytest.mark.asyncio +async def test_save_to_store_failure_wrapped() -> None: + class BrokenApplyLayer(StubLayer): + async def _save_to_store(self, _next_config: BaseModel) -> str: + raise OSError("disk full") + + layer = BrokenApplyLayer(data={"key": "old"}) + await layer.load() + fingerprint = layer.fingerprint + assert isinstance(fingerprint, str) + + with pytest.raises(LayerImplementationError, match="_save_to_store") as exc_info: + await layer.apply( + ConfigPatch( + ReplaceOperationPatch(path="/key", value="new"), fingerprint=fingerprint + ) + ) + + assert isinstance(exc_info.value.__cause__, OSError) @pytest.mark.asyncio @@ -499,6 +673,9 @@ class FakeLocalUserLayer(ConfigLayer[UserConfigSchema]): async def _build_config_snapshot(self) -> LayerConfigSnapshot: return LayerConfigSnapshot(data=dict(self._data), fingerprint="user-fp") + async def _save_to_store(self, _next_config: UserConfigSchema) -> str: + raise NotImplementedError + @pytest.mark.asyncio async def test_scenario_local_user_layer_always_trusted() -> None: @@ -544,6 +721,9 @@ class FakeLocalProjectLayer(ConfigLayer[BaseModel]): async def _build_config_snapshot(self) -> LayerConfigSnapshot: return LayerConfigSnapshot(data=dict(self._data), fingerprint="project-fp") + async def _save_to_store(self, _next_config: BaseModel) -> str: + raise NotImplementedError + @pytest.mark.asyncio async def test_scenario_local_project_layer_trust_lifecycle() -> None: @@ -561,7 +741,7 @@ async def test_scenario_local_project_layer_trust_lifecycle() -> None: await layer.grant_trust() assert trust_store == {"/tmp/my-project": True} result = await layer.load() - assert result.model_extra == project_data + assert result.model_dump() == project_data # 3. New instance with same trust store — loads directly layer2 = FakeLocalProjectLayer( @@ -570,7 +750,7 @@ async def test_scenario_local_project_layer_trust_lifecycle() -> None: assert layer2.is_trusted is None result2 = await layer2.load() assert layer2.is_trusted is True - assert result2.model_extra == project_data + assert result2.model_dump() == project_data # 4. Revoke trust — removed from store, load raises await layer2.revoke_trust() diff --git a/tests/core/test_config_load_dotenv.py b/tests/core/test_config_load_dotenv.py index 19ed0fc..a7e79d4 100644 --- a/tests/core/test_config_load_dotenv.py +++ b/tests/core/test_config_load_dotenv.py @@ -20,7 +20,7 @@ def test_skips_missing_file(tmp_path: Path) -> None: assert environ == {"EXISTING": "1"} -def test_sets_and_overrides_values(tmp_path: Path) -> None: +def test_adds_missing_values_without_overriding_existing(tmp_path: Path) -> None: env_path = tmp_path / ".env" _write_env_file( env_path, @@ -42,11 +42,23 @@ def test_sets_and_overrides_values(tmp_path: Path) -> None: load_dotenv_values(env_path=env_path, environ=environ) - assert environ["MISTRAL_API_KEY"] == "new-key" - assert environ["HTTPS_PROXY"] == "https://local-proxy:8080" - assert environ["OTHER"] == "from-env" + # An explicit process/shell value wins over the .env file. + assert environ["MISTRAL_API_KEY"] == "old-key" + assert environ["HTTPS_PROXY"] == "old-https" + assert environ["OTHER"] == "keep" + assert environ["FOO"] == "keep" + # Keys absent from the process env are still loaded from the .env file. assert environ["NEW_KEY"] == "added" - assert environ["FOO"] == "replace" + + +def test_adds_dotenv_value_when_process_env_is_empty(tmp_path: Path) -> None: + env_path = tmp_path / ".env" + _write_env_file(env_path, "MISTRAL_API_KEY=file-key\n") + environ = {"MISTRAL_API_KEY": ""} + + load_dotenv_values(env_path=env_path, environ=environ) + + assert environ["MISTRAL_API_KEY"] == "file-key" def test_ignores_empty_values(tmp_path: Path) -> None: diff --git a/tests/core/test_config_mcp_auth.py b/tests/core/test_config_mcp_auth.py index 49a6157..5863762 100644 --- a/tests/core/test_config_mcp_auth.py +++ b/tests/core/test_config_mcp_auth.py @@ -1,11 +1,12 @@ from __future__ import annotations -import logging +from unittest.mock import AsyncMock, MagicMock, patch from pydantic import ValidationError import pytest from vibe.core.config import MCPHttp, MCPOAuth, MCPStaticAuth, MCPStreamableHttp +from vibe.core.tools.mcp import AuthStatus from vibe.core.tools.mcp.registry import MCPRegistry HTTP_TRANSPORTS = [ @@ -183,10 +184,8 @@ def test_oauth_scopes_empty_list_allowed() -> None: @pytest.mark.asyncio @pytest.mark.parametrize(("cls", "transport"), HTTP_TRANSPORTS) -async def test_registry_skips_oauth_servers_with_gated_warning( - cls: type[MCPHttp | MCPStreamableHttp], - transport: str, - caplog: pytest.LogCaptureFixture, +async def test_registry_marks_oauth_servers_without_tokens_as_needing_auth( + cls: type[MCPHttp | MCPStreamableHttp], transport: str ) -> None: srv = cls.model_validate({ "name": "linear", @@ -195,19 +194,23 @@ async def test_registry_skips_oauth_servers_with_gated_warning( "auth": {"type": "oauth", "scopes": ["read"]}, }) registry = MCPRegistry() + storage = MagicMock() + storage.get_tokens = AsyncMock(return_value=None) + storage.delete_tokens = AsyncMock() + storage.delete_client_info = AsyncMock() - with caplog.at_level(logging.WARNING, logger="vibe"): + with ( + patch("vibe.core.tools.mcp.registry.KeyringTokenStorage", return_value=storage), + patch( + "vibe.core.tools.mcp.registry.Fingerprint.load", + new=AsyncMock(return_value=None), + ), + patch("vibe.core.tools.mcp.registry.Fingerprint.delete", new=AsyncMock()), + ): first = await registry.get_tools_async([srv]) - - assert first == {} - assert ( - "OAuth support for MCP servers is not yet enabled; coming in a future release" - in caplog.text - ) - - caplog.clear() - with caplog.at_level(logging.WARNING, logger="vibe"): second = await registry.get_tools_async([srv]) + assert first == {} assert second == {} - assert "OAuth support" not in caplog.text + assert registry.needs_auth == {"linear"} + assert registry.status()["linear"] == AuthStatus.NEEDS_AUTH diff --git a/tests/core/test_config_orchestrator.py b/tests/core/test_config_orchestrator.py index 4523084..be49d63 100644 --- a/tests/core/test_config_orchestrator.py +++ b/tests/core/test_config_orchestrator.py @@ -23,6 +23,9 @@ class FakeLayer(ConfigLayer[RawConfig]): async def _build_config_snapshot(self) -> LayerConfigSnapshot: return LayerConfigSnapshot(data=dict(self._data), fingerprint="fp") + async def _save_to_store(self, _next_config: RawConfig) -> str: + raise NotImplementedError + class SimpleSchema(ConfigSchema): value: Annotated[str, WithReplaceMerge()] = "default" diff --git a/tests/core/test_config_otel.py b/tests/core/test_config_otel.py index bd38ee6..f224766 100644 --- a/tests/core/test_config_otel.py +++ b/tests/core/test_config_otel.py @@ -2,6 +2,7 @@ from __future__ import annotations import pytest +from tests.constants import ANTHROPIC_BASE_URL from vibe.core.config import OtelSpanExporterConfig, ProviderConfig, VibeConfig from vibe.core.types import Backend @@ -62,7 +63,7 @@ class TestOtelSpanExporterConfig: update={ "providers": [ ProviderConfig( - name="anthropic", api_base="https://api.anthropic.com/v1" + name="anthropic", api_base=f"{ANTHROPIC_BASE_URL}/v1" ) ] } @@ -80,6 +81,18 @@ class TestOtelSpanExporterConfig: assert result is not None assert result.endpoint == "https://api.mistral.ai/telemetry/v1/traces" + def test_resolves_api_key_from_keyring( + self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Key stored only in the OS keyring (no env var) must still authenticate OTEL. + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + monkeypatch.setattr( + "keyring.get_password", lambda service, username: "sk-keyring" + ) + result = vibe_config.otel_span_exporter_config + assert result is not None + assert result.headers == {"Authorization": "Bearer sk-keyring"} + def test_returns_none_and_warns_when_api_key_missing( self, vibe_config: VibeConfig, diff --git a/tests/core/test_config_patch_ops.py b/tests/core/test_config_patch_ops.py index 06b268e..fd73a56 100644 --- a/tests/core/test_config_patch_ops.py +++ b/tests/core/test_config_patch_ops.py @@ -1,135 +1,84 @@ from __future__ import annotations -from collections.abc import Callable -from dataclasses import FrozenInstanceError from typing import Any, get_args +from pydantic import ValidationError import pytest from vibe.core.config import ( - AppendToList, - DeleteField, - PatchOp, - RemoveFromList, - SetField, + AddOperationPatch, + RemoveOperationPatch, + ReplaceOperationPatch, ) -from vibe.core.config.patch import ConfigPatch +from vibe.core.config.patch import ConfigPatch, PatchOp + + +@pytest.mark.parametrize( + ("operation", "expected"), + [ + ( + AddOperationPatch(path="/tools/disabled_tools/0", value="bash"), + {"op": "add", "path": "/tools/disabled_tools/0", "value": "bash"}, + ), + ( + ReplaceOperationPatch(path="/active_model", value="devstral-small"), + {"op": "replace", "path": "/active_model", "value": "devstral-small"}, + ), + ( + RemoveOperationPatch(path="/tools/deprecated_setting"), + {"op": "remove", "path": "/tools/deprecated_setting"}, + ), + ], +) +def test_json_patch_operations_convert_to_json_patch_payload( + operation: AddOperationPatch | ReplaceOperationPatch | RemoveOperationPatch, + expected: dict[str, Any], +) -> None: + assert operation.to_json_patch() == expected def test_patch_op_union_contains_all_operations() -> None: - assert get_args(PatchOp) == (SetField, AppendToList, RemoveFromList, DeleteField) - - -def test_set_field_accepts_top_level_key() -> None: - op = SetField("active_model", "devstral-small") - - assert op.key == "active_model" - assert op.value == "devstral-small" - - -def test_set_field_accepts_nested_key() -> None: - op = SetField("models.providers", {"mistral": {"region": "eu"}}) - - assert op.key == "models.providers" - - -def test_append_to_list_accepts_nested_key() -> None: - op = AppendToList("tools.disabled_tools", ("bash", "python")) - - assert op.key == "tools.disabled_tools" - assert op.items == ("bash", "python") - - -def test_remove_from_list_accepts_nested_key() -> None: - op = RemoveFromList("models.available_models", ("codestral-latest",)) - - assert op.key == "models.available_models" - assert op.values == ("codestral-latest",) - - -def test_delete_field_accepts_nested_key() -> None: - op = DeleteField("tools.deprecated_setting") - - assert op.key == "tools.deprecated_setting" + assert set(get_args(PatchOp.__value__)) == { + AddOperationPatch, + ReplaceOperationPatch, + RemoveOperationPatch, + } @pytest.mark.parametrize( "factory", [ - lambda key: SetField(key, "value"), - lambda key: AppendToList(key, ("value",)), - lambda key: RemoveFromList(key, ("value",)), - lambda key: DeleteField(key), + lambda path: AddOperationPatch(path=path, value="value"), + lambda path: ReplaceOperationPatch(path=path, value="value"), + lambda path: RemoveOperationPatch(path=path), ], ) -@pytest.mark.parametrize( - "invalid_key", ["", ".active_model", "active_model.", "tools..bash"] -) -def test_patch_operations_reject_invalid_key_paths( - factory: Callable[[str], object], invalid_key: str -) -> None: - with pytest.raises(ValueError, match="dot-separated path|must not be empty"): - factory(invalid_key) +def test_json_patch_operations_reject_non_pointer_paths(factory: Any) -> None: + with pytest.raises(ValidationError, match="valid JSON Pointer"): + factory("tools.disabled_tools") @pytest.mark.parametrize( "factory", [ - lambda key: SetField(key, "value"), - lambda key: AppendToList(key, ("value",)), - lambda key: RemoveFromList(key, ("value",)), - lambda key: DeleteField(key), + lambda path: AddOperationPatch(path=path, value="value"), + lambda path: ReplaceOperationPatch(path=path, value="value"), + lambda path: RemoveOperationPatch(path=path), ], ) -def test_patch_operations_reject_non_string_keys( - factory: Callable[[Any], object], -) -> None: - with pytest.raises(TypeError, match="Patch operation key must be a string"): - factory(1) +def test_json_patch_operations_reject_invalid_escapes(factory: Any) -> None: + with pytest.raises(ValidationError, match="valid JSON Pointer"): + factory("/tools/~2") -def test_append_to_list_rejects_non_tuple_items() -> None: - bad_items: Any = ["bash"] +def test_json_patch_operations_accept_slash_prefixed_paths() -> None: + op = ReplaceOperationPatch(path="/", value={"active_model": "devstral-small"}) - with pytest.raises(TypeError, match="AppendToList.items must be a tuple"): - AppendToList("tools.disabled_tools", bad_items) - - -def test_remove_from_list_rejects_non_tuple_values() -> None: - bad_values: Any = ["bash"] - - with pytest.raises(TypeError, match="RemoveFromList.values must be a tuple"): - RemoveFromList("tools.disabled_tools", bad_values) - - -def test_patch_operations_are_frozen() -> None: - op = SetField("active_model", "devstral-small") - - with pytest.raises(FrozenInstanceError): - op.__setattr__("key", "models.active_model") - - -def test_scenario_mini_vibe_patch_operations() -> None: - operations: list[PatchOp] = [ - SetField("active_model", "devstral-small"), - AppendToList("tools.disabled_tools", ("bash",)), - RemoveFromList("models.available_models", ("codestral-latest",)), - DeleteField("tools.deprecated_setting"), - ] - - assert operations == [ - SetField("active_model", "devstral-small"), - AppendToList("tools.disabled_tools", ("bash",)), - RemoveFromList("models.available_models", ("codestral-latest",)), - DeleteField("tools.deprecated_setting"), - ] - - -# --- ConfigPatch --- + assert op.path == "/" def test_config_patch_stores_operations_and_metadata() -> None: - op = SetField("active_model", "devstral-small") + op = ReplaceOperationPatch(path="/active_model", value="devstral-small") patch = ConfigPatch(op, fingerprint="fp-1", reason="test") assert patch.operations == [op] @@ -145,9 +94,9 @@ def test_config_patch_defaults() -> None: def test_config_patch_accepts_multiple_operations() -> None: - ops: list[PatchOp] = [ - SetField("active_model", "devstral-small"), - AppendToList("tools.disabled_tools", ("bash",)), + ops = [ + ReplaceOperationPatch(path="/active_model", value="devstral-small"), + AddOperationPatch(path="/tools/disabled_tools/-", value="bash"), ] patch = ConfigPatch(*ops, fingerprint="fp-1") @@ -155,18 +104,23 @@ def test_config_patch_accepts_multiple_operations() -> None: def test_config_patch_add_appends_operations() -> None: - patch = ConfigPatch(SetField("active_model", "devstral-small"), fingerprint="fp-1") - patch.add(DeleteField("tools.deprecated_setting")) + patch = ConfigPatch( + ReplaceOperationPatch(path="/active_model", value="devstral-small"), + fingerprint="fp-1", + ) + patch.add(RemoveOperationPatch(path="/tools/deprecated_setting")) assert patch.operations == [ - SetField("active_model", "devstral-small"), - DeleteField("tools.deprecated_setting"), + ReplaceOperationPatch(path="/active_model", value="devstral-small"), + RemoveOperationPatch(path="/tools/deprecated_setting"), ] def test_config_patch_add_returns_self() -> None: patch = ConfigPatch(fingerprint="fp-1") - result = patch.add(SetField("active_model", "devstral-small")) + result = patch.add( + ReplaceOperationPatch(path="/active_model", value="devstral-small") + ) assert result is patch @@ -174,8 +128,8 @@ def test_config_patch_add_returns_self() -> None: def test_config_patch_add_accepts_multiple_operations() -> None: patch = ConfigPatch(fingerprint="fp-1") patch.add( - SetField("active_model", "devstral-small"), - AppendToList("tools.disabled_tools", ("bash",)), + ReplaceOperationPatch(path="/active_model", value="devstral-small"), + AddOperationPatch(path="/tools/disabled_tools/-", value="bash"), ) assert len(patch.operations) == 2 @@ -184,42 +138,52 @@ def test_config_patch_add_accepts_multiple_operations() -> None: def test_config_patch_add_is_chainable() -> None: patch = ( ConfigPatch(fingerprint="fp-1") - .add(SetField("active_model", "devstral-small")) - .add(DeleteField("tools.deprecated_setting")) + .add(ReplaceOperationPatch(path="/active_model", value="devstral-small")) + .add(RemoveOperationPatch(path="/tools/deprecated_setting")) ) assert len(patch.operations) == 2 -def test_config_patch_describe_set_field() -> None: - patch = ConfigPatch(SetField("active_model", "devstral-small"), fingerprint="fp-1") - - assert patch.describe() == ["set 'active_model' = 'devstral-small'"] - - -def test_config_patch_describe_append_to_list() -> None: +def test_config_patch_to_json_patch_from_wrappers() -> None: patch = ConfigPatch( - AppendToList("tools.disabled_tools", ("bash", "python")), fingerprint="fp-1" - ) - - assert patch.describe() == ["append to 'tools.disabled_tools': ['bash', 'python']"] - - -def test_config_patch_describe_remove_from_list() -> None: - patch = ConfigPatch( - RemoveFromList("models.available_models", ("codestral-latest",)), + ReplaceOperationPatch(path="/active_model", value="devstral-small"), + AddOperationPatch(path="/tools/disabled_tools/-", value="bash"), + RemoveOperationPatch(path="/tools/deprecated_setting"), fingerprint="fp-1", ) - assert patch.describe() == [ - "remove from 'models.available_models': ['codestral-latest']" + assert patch.to_json_patch() == [ + {"op": "replace", "path": "/active_model", "value": "devstral-small"}, + {"op": "add", "path": "/tools/disabled_tools/-", "value": "bash"}, + {"op": "remove", "path": "/tools/deprecated_setting"}, ] -def test_config_patch_describe_delete_field() -> None: - patch = ConfigPatch(DeleteField("tools.deprecated_setting"), fingerprint="fp-1") +def test_config_patch_describe_add_operation() -> None: + patch = ConfigPatch( + AddOperationPatch(path="/tools/disabled_tools/-", value="bash"), + fingerprint="fp-1", + ) - assert patch.describe() == ["delete 'tools.deprecated_setting'"] + assert patch.describe() == ["add '/tools/disabled_tools/-' = 'bash'"] + + +def test_config_patch_describe_replace_operation() -> None: + patch = ConfigPatch( + ReplaceOperationPatch(path="/active_model", value="devstral-small"), + fingerprint="fp-1", + ) + + assert patch.describe() == ["replace '/active_model' = 'devstral-small'"] + + +def test_config_patch_describe_remove_operation() -> None: + patch = ConfigPatch( + RemoveOperationPatch(path="/tools/deprecated_setting"), fingerprint="fp-1" + ) + + assert patch.describe() == ["remove '/tools/deprecated_setting'"] def test_config_patch_describe_empty_returns_empty_list() -> None: @@ -230,28 +194,28 @@ def test_config_patch_describe_empty_returns_empty_list() -> None: def test_config_patch_describe_multiple_operations() -> None: patch = ConfigPatch( - SetField("active_model", "devstral-small"), - AppendToList("tools.disabled_tools", ("bash",)), - DeleteField("tools.deprecated_setting"), + ReplaceOperationPatch(path="/active_model", value="devstral-small"), + AddOperationPatch(path="/tools/disabled_tools/-", value="bash"), + RemoveOperationPatch(path="/tools/deprecated_setting"), fingerprint="fp-1", ) assert patch.describe() == [ - "set 'active_model' = 'devstral-small'", - "append to 'tools.disabled_tools': ['bash']", - "delete 'tools.deprecated_setting'", + "replace '/active_model' = 'devstral-small'", + "add '/tools/disabled_tools/-' = 'bash'", + "remove '/tools/deprecated_setting'", ] def test_scenario_build_patch_incrementally() -> None: patch = ConfigPatch(fingerprint="fp-abc", reason="/model command") - patch.add(SetField("active_model", "devstral-small")) - patch.add(AppendToList("tools.disabled_tools", ("bash",))) + patch.add(ReplaceOperationPatch(path="/active_model", value="devstral-small")) + patch.add(AddOperationPatch(path="/tools/disabled_tools/-", value="bash")) assert patch.fingerprint == "fp-abc" assert patch.reason == "/model command" assert len(patch.operations) == 2 assert patch.describe() == [ - "set 'active_model' = 'devstral-small'", - "append to 'tools.disabled_tools': ['bash']", + "replace '/active_model' = 'devstral-small'", + "add '/tools/disabled_tools/-' = 'bash'", ] diff --git a/tests/core/test_fingerprint.py b/tests/core/test_fingerprint.py index 8a4733e..6d15418 100644 --- a/tests/core/test_fingerprint.py +++ b/tests/core/test_fingerprint.py @@ -4,7 +4,11 @@ from pathlib import Path import pytest -from vibe.core.config.fingerprint import capture_stable_file, create_dict_fingerprint +from vibe.core.config.fingerprint import ( + capture_stable_file, + create_dict_fingerprint, + create_file_fingerprint, +) from vibe.core.config.types import ConcurrencyConflictError @@ -67,6 +71,32 @@ class TestCaptureStableFile: pass +class TestCreateFileFingerprint: + def test_captures_file_state(self, tmp_working_directory: Path) -> None: + path = tmp_working_directory / "config.toml" + path.write_text("key = 1") + + with path.open("rb") as file: + first_fingerprint = create_file_fingerprint(file) + with path.open("rb") as file: + second_fingerprint = create_file_fingerprint(file) + + assert isinstance(first_fingerprint, str) + assert first_fingerprint + assert first_fingerprint == second_fingerprint + + def test_changes_when_file_changes(self, tmp_working_directory: Path) -> None: + path = tmp_working_directory / "config.toml" + path.write_text("key = 1") + with path.open("rb") as file: + first_fingerprint = create_file_fingerprint(file) + + path.write_text("key = 2") + + with path.open("rb") as file: + assert create_file_fingerprint(file) != first_fingerprint + + class TestCreateDictFingerprint: def test_empty_dict_returns_stable_non_empty_token(self) -> None: first_fingerprint = create_dict_fingerprint({}) diff --git a/tests/core/test_image_attachment.py b/tests/core/test_image_attachment.py new file mode 100644 index 0000000..56ba927 --- /dev/null +++ b/tests/core/test_image_attachment.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from pathlib import Path + +from pydantic import ValidationError +import pytest + +from vibe.core.types import FileImageSource, ImageAttachment, InlineImageSource + + +def test_migrates_legacy_flat_path_shape() -> None: + att = ImageAttachment.model_validate({ + "path": "/tmp/a.png", + "alias": "a.png", + "mime_type": "image/png", + }) + + assert isinstance(att.source, FileImageSource) + assert att.source.path == Path("/tmp/a.png") + + +def test_migrates_legacy_flat_data_shape() -> None: + att = ImageAttachment.model_validate({ + "data": "Zm9v", + "alias": "pasted.png", + "mime_type": "image/png", + }) + + assert isinstance(att.source, InlineImageSource) + assert att.source.data == "Zm9v" + + +def test_source_path_construction() -> None: + att = ImageAttachment( + source=FileImageSource(path=Path("/tmp/a.png")), + alias="a.png", + mime_type="image/png", + ) + + assert isinstance(att.source, FileImageSource) + assert att.source.path == Path("/tmp/a.png") + + +def test_file_source_round_trips_through_json() -> None: + att = ImageAttachment( + source=FileImageSource(path=Path("/tmp/a.png")), + alias="a.png", + mime_type="image/png", + ) + + dumped = att.model_dump(exclude_none=True, mode="json") + assert dumped["source"] == {"kind": "file", "path": "/tmp/a.png"} + assert ImageAttachment.model_validate(dumped) == att + + +def test_rejects_attachment_without_source() -> None: + with pytest.raises(ValidationError): + ImageAttachment.model_validate({"alias": "a.png", "mime_type": "image/png"}) diff --git a/tests/core/test_llm_message_merge.py b/tests/core/test_llm_message_merge.py index a82e0d0..2e75ab7 100644 --- a/tests/core/test_llm_message_merge.py +++ b/tests/core/test_llm_message_merge.py @@ -4,27 +4,43 @@ from pathlib import Path import pytest -from vibe.core.types import ImageAttachment, LLMMessage, Role +from vibe.core.types import ( + FileImageSource, + ImageAttachment, + LLMMessage, + Role, + UserDisplayContentMetadata, +) @pytest.fixture() def image_a(tmp_path: Path) -> ImageAttachment: p = tmp_path / "a.png" p.write_bytes(b"\x89PNG") - return ImageAttachment(path=p, alias="a.png", mime_type="image/png") + return ImageAttachment( + source=FileImageSource(path=p), alias="a.png", mime_type="image/png" + ) @pytest.fixture() def image_b(tmp_path: Path) -> ImageAttachment: p = tmp_path / "b.png" p.write_bytes(b"\x89PNG") - return ImageAttachment(path=p, alias="b.png", mime_type="image/png") + return ImageAttachment( + source=FileImageSource(path=p), alias="b.png", mime_type="image/png" + ) def _msg(content: str, images: list[ImageAttachment] | None = None) -> LLMMessage: return LLMMessage(role=Role.assistant, content=content, images=images) +def _display_content(host: str) -> UserDisplayContentMetadata: + return UserDisplayContentMetadata( + version="1.0.0", host=host, content=[{"type": "text", "text": host}] + ) + + def test_merge_prefers_self_images_when_present( image_a: ImageAttachment, image_b: ImageAttachment ) -> None: @@ -49,3 +65,26 @@ def test_merge_preserves_explicit_empty_self_images_over_other( def test_merge_yields_none_when_both_sides_are_none() -> None: merged = _msg("hi") + _msg(" there") assert merged.images is None + + +def test_merge_prefers_self_user_display_content_when_present() -> None: + self_display_content = _display_content("mistral-vscode") + other_display_content = _display_content("mistral-jetbrains") + + merged = LLMMessage( + role=Role.user, content="hi", user_display_content=self_display_content + ) + LLMMessage( + role=Role.user, content=" there", user_display_content=other_display_content + ) + + assert merged.user_display_content == self_display_content + + +def test_merge_falls_back_to_other_user_display_content() -> None: + other_display_content = _display_content("mistral-vscode") + + merged = LLMMessage(role=Role.user, content="hi") + LLMMessage( + role=Role.user, content=" there", user_display_content=other_display_content + ) + + assert merged.user_display_content == other_display_content diff --git a/tests/core/test_mcp_oauth.py b/tests/core/test_mcp_oauth.py index 65885f5..a168105 100644 --- a/tests/core/test_mcp_oauth.py +++ b/tests/core/test_mcp_oauth.py @@ -4,6 +4,8 @@ import asyncio from collections.abc import Callable, Iterator from contextlib import suppress import socket +from types import TracebackType +from unittest.mock import patch import urllib.parse import httpx @@ -11,6 +13,7 @@ import keyring from keyring.backend import KeyringBackend import keyring.backends.fail import keyring.errors +from mcp.client.auth import OAuthFlowError from mcp.shared.auth import OAuthClientInformationFull, OAuthToken import pytest import respx @@ -21,6 +24,7 @@ from vibe.core.auth.mcp_oauth import ( LoopbackCallbackHandler, MCPOAuthError, MCPOAuthHeadlessError, + MCPOAuthLoginFailed, MCPOAuthPortInUse, build_oauth_provider, perform_oauth_login, @@ -366,6 +370,26 @@ class TestBuildOAuthProvider: ) assert provider.context.client_metadata_url == "https://vibe.example/cm.json" + @pytest.mark.asyncio + async def test_client_id_is_exposed_as_fallback_client_info( + self, memory_keyring: _MemoryKeyring + ) -> None: + srv = _oauth_server(client_id="pre-registered-client") + + async def on_url(_url: str) -> None: + return None + + async def cb() -> tuple[str, str | None]: + return "code", None + + provider = build_oauth_provider( + srv, redirect_handler=on_url, callback_handler=cb + ) + client_info = await provider.context.storage.get_client_info() + + assert client_info is not None + assert client_info.client_id == "pre-registered-client" + @pytest.mark.asyncio async def test_rejects_static_auth(self, memory_keyring: _MemoryKeyring) -> None: from vibe.core.config import MCPStaticAuth @@ -388,6 +412,39 @@ class TestBuildOAuthProvider: class TestPerformOAuthLogin: + @pytest.mark.asyncio + async def test_oauth_flow_error_becomes_login_failed( + self, memory_keyring: _MemoryKeyring + ) -> None: + srv = _oauth_server(name="demo") + + class OAuthFlowFailingClient: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + async def __aenter__(self) -> OAuthFlowFailingClient: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + pass + + async def get(self, _url: str) -> None: + raise OAuthFlowError("cancelled") + + async def on_url(_url: str) -> None: + pass + + with patch( + "vibe.core.auth.mcp_oauth.httpx.AsyncClient", new=OAuthFlowFailingClient + ): + with pytest.raises(MCPOAuthLoginFailed, match="cancelled"): + await perform_oauth_login(srv, on_url=on_url) + @pytest.mark.asyncio async def test_full_flow_persists_tokens_and_fingerprint( self, memory_keyring: _MemoryKeyring diff --git a/tests/core/test_message_list.py b/tests/core/test_message_list.py new file mode 100644 index 0000000..967025d --- /dev/null +++ b/tests/core/test_message_list.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from vibe.core.types import LLMMessage, MessageList, Role + + +def test_update_system_prompt_replaces_existing_system_slot() -> None: + messages = MessageList( + initial=[ + LLMMessage(role=Role.system, content="old"), + LLMMessage(role=Role.user, content="hi"), + ] + ) + + messages.update_system_prompt("new") + + assert len(messages) == 2 + assert messages[0].role == Role.system + assert messages[0].content == "new" + assert messages[1].content == "hi" + + +def test_update_system_prompt_inserts_without_clobbering_when_no_system() -> None: + messages = MessageList( + initial=[ + LLMMessage(role=Role.user, content="Hello"), + LLMMessage(role=Role.assistant, content="Hi there!"), + ] + ) + + messages.update_system_prompt("system prompt") + + assert len(messages) == 3 + assert messages[0].role == Role.system + assert messages[1].content == "Hello" + assert messages[2].content == "Hi there!" + + +def test_update_system_prompt_inserts_into_empty_list() -> None: + messages = MessageList() + + messages.update_system_prompt("system prompt") + + assert len(messages) == 1 + assert messages[0].role == Role.system + + +def test_update_system_prompt_notifies_only_when_requested() -> None: + observed: list[LLMMessage] = [] + messages = MessageList(observer=observed.append) + + messages.update_system_prompt("silent") + assert observed == [] + + messages.update_system_prompt("loud", notify=True) + assert len(observed) == 1 + assert observed[0].content == "loud" diff --git a/tests/core/test_overrides_layer.py b/tests/core/test_overrides_layer.py index 2b385d1..b008de8 100644 --- a/tests/core/test_overrides_layer.py +++ b/tests/core/test_overrides_layer.py @@ -3,7 +3,6 @@ from __future__ import annotations import pytest from vibe.core.config.layers.overrides import OverridesLayer -from vibe.core.config.patch import ConfigPatch @pytest.mark.asyncio @@ -20,13 +19,6 @@ async def test_always_trusted() -> None: assert await layer.resolve_trust() is True -@pytest.mark.asyncio -async def test_apply_raises_not_implemented() -> None: - layer = OverridesLayer(data={}) - with pytest.raises(NotImplementedError, match="M2"): - await layer.apply(ConfigPatch(fingerprint="fp-1")) - - @pytest.mark.asyncio async def test_default_name() -> None: layer = OverridesLayer(data={}) diff --git a/tests/core/test_project_config_layer.py b/tests/core/test_project_config_layer.py index 123a1f9..182c848 100644 --- a/tests/core/test_project_config_layer.py +++ b/tests/core/test_project_config_layer.py @@ -6,7 +6,6 @@ import pytest from vibe.core.config.layer import UntrustedLayerError from vibe.core.config.layers.project import ProjectConfigLayer -from vibe.core.config.patch import ConfigPatch from vibe.core.config.types import MISSING_CONFIG_FILE_FINGERPRINT from vibe.core.paths._vibe_home import GlobalPath from vibe.core.trusted_folders import trusted_folders_manager @@ -73,13 +72,6 @@ async def test_default_name(tmp_working_directory: Path) -> None: assert layer.name == "project-toml" -@pytest.mark.asyncio -async def test_apply_raises_not_implemented(tmp_working_directory: Path) -> None: - layer = ProjectConfigLayer(path=tmp_working_directory) - with pytest.raises(NotImplementedError, match="M2"): - await layer.apply(ConfigPatch(fingerprint="fp-1")) - - @pytest.mark.asyncio async def test_trust_uses_path_parent_for_resolution( tmp_working_directory: Path, diff --git a/tests/core/test_remote_agent_loop.py b/tests/core/test_remote_agent_loop.py deleted file mode 100644 index 9cfb888..0000000 --- a/tests/core/test_remote_agent_loop.py +++ /dev/null @@ -1,937 +0,0 @@ -from __future__ import annotations - -from typing import Any -from unittest.mock import patch - -from tests.conftest import build_test_vibe_config -from vibe.core.nuage.events import ( - CustomTaskCanceled, - CustomTaskCanceledAttributes, - CustomTaskCompleted, - CustomTaskCompletedAttributes, - CustomTaskInProgress, - CustomTaskInProgressAttributes, - CustomTaskStarted, - CustomTaskStartedAttributes, - JSONPatchAdd, - JSONPatchAppend, - JSONPatchPayload, - JSONPatchReplace, - JSONPayload, -) -from vibe.core.nuage.remote_events_source import RemoteEventsSource -from vibe.core.types import ( - AssistantEvent, - ReasoningEvent, - Role, - ToolCallEvent, - ToolResultEvent, - ToolStreamEvent, - UserMessageEvent, - WaitingForInputEvent, -) - -_EXEC_ID = "session-123" - - -def _make_loop(enabled_tools: list[str] | None = None) -> RemoteEventsSource: - config = build_test_vibe_config(enabled_tools=enabled_tools or []) - return RemoteEventsSource(session_id=_EXEC_ID, config=config) - - -def _started( - task_id: str, task_type: str, payload: dict[str, Any] -) -> CustomTaskStarted: - return CustomTaskStarted( - event_id=f"evt-{task_id}-start", - workflow_exec_id=_EXEC_ID, - attributes=CustomTaskStartedAttributes( - custom_task_id=task_id, - custom_task_type=task_type, - payload=JSONPayload(value=payload), - ), - ) - - -def _completed( - task_id: str, task_type: str, payload: dict[str, Any] -) -> CustomTaskCompleted: - return CustomTaskCompleted( - event_id=f"evt-{task_id}-done", - workflow_exec_id=_EXEC_ID, - attributes=CustomTaskCompletedAttributes( - custom_task_id=task_id, - custom_task_type=task_type, - payload=JSONPayload(value=payload), - ), - ) - - -def _in_progress( - task_id: str, task_type: str, patches: list[Any] -) -> CustomTaskInProgress: - return CustomTaskInProgress( - event_id=f"evt-{task_id}-progress", - workflow_exec_id=_EXEC_ID, - attributes=CustomTaskInProgressAttributes( - custom_task_id=task_id, - custom_task_type=task_type, - payload=JSONPatchPayload(value=patches), - ), - ) - - -def _canceled(task_id: str, task_type: str, reason: str = "") -> CustomTaskCanceled: - return CustomTaskCanceled( - event_id=f"evt-{task_id}-cancel", - workflow_exec_id=_EXEC_ID, - attributes=CustomTaskCanceledAttributes( - custom_task_id=task_id, custom_task_type=task_type, reason=reason - ), - ) - - -def test_consume_wait_for_input_event_emits_waiting_event() -> None: - loop = _make_loop() - event = _started( - "wait-task-1", - "wait_for_input", - { - "task_id": "wait-task-1", - "input_schema": {"title": "ChatInput"}, - "label": "What next?", - }, - ) - - emitted_events = loop._consume_workflow_event(event) - - assert len(emitted_events) == 2 - assistant_event = emitted_events[0] - waiting_event = emitted_events[1] - assert isinstance(assistant_event, AssistantEvent) - assert assistant_event.content == "What next?" - assert isinstance(waiting_event, WaitingForInputEvent) - assert waiting_event.task_id == "wait-task-1" - assert waiting_event.label == "What next?" - assert waiting_event.predefined_answers is None - - -def test_consume_agent_input_keeps_repeated_text_across_distinct_turns() -> None: - loop = _make_loop() - first_event = _completed( - "input-1", "AgentInputState", {"input": {"message": [{"text": "continue"}]}} - ) - second_event = _completed( - "input-2", "AgentInputState", {"input": {"message": [{"text": "continue"}]}} - ) - - assert loop._consume_workflow_event(first_event) == [] - assert loop._consume_workflow_event(second_event) == [] - - assert [msg.content for msg in loop.messages if msg.role == Role.user] == [ - "continue", - "continue", - ] - - -def test_wait_for_input_emits_predefined_answers_and_user_message() -> None: - loop = _make_loop() - started = _started( - "wait-task-1", - "wait_for_input", - { - "input_schema": { - "title": "ChatInput", - "properties": { - "message": { - "examples": [ - [{"type": "text", "text": "Python"}], - [{"type": "text", "text": "JavaScript"}], - [{"type": "text", "text": "Other"}], - ] - } - }, - }, - "label": "Which language?", - }, - ) - completed = _completed( - "wait-task-1", - "wait_for_input", - {"input": {"message": [{"type": "text", "text": "Python"}]}}, - ) - - started_events = loop._consume_workflow_event(started) - completed_events = loop._consume_workflow_event(completed) - - assistant_event = next( - event for event in started_events if isinstance(event, AssistantEvent) - ) - waiting_event = next( - event for event in started_events if isinstance(event, WaitingForInputEvent) - ) - assert assistant_event.content == "Which language?" - assert waiting_event.predefined_answers == ["Python", "JavaScript"] - user_event = next( - event for event in completed_events if isinstance(event, UserMessageEvent) - ) - assert user_event.content == "Python" - - -def test_tool_events_update_stats_and_messages() -> None: - loop = _make_loop(enabled_tools=["todo"]) - started = _started( - "tool-task-1", - "AgentToolCallState", - {"name": "todo", "tool_call_id": "call-1", "kwargs": {"action": "read"}}, - ) - completed = _completed( - "tool-task-1", - "AgentToolCallState", - { - "name": "todo", - "tool_call_id": "call-1", - "kwargs": {"action": "read"}, - "output": {"total_count": 0}, - }, - ) - - started_events = loop._consume_workflow_event(started) - completed_events = loop._consume_workflow_event(completed) - - assert any(isinstance(event, ToolCallEvent) for event in started_events) - result_event = next( - event for event in completed_events if isinstance(event, ToolResultEvent) - ) - assert result_event.error is None - assert result_event.cancelled is False - assert result_event.tool_call_id == "call-1" - assert loop.stats.tool_calls_agreed == 1 - assert loop.stats.tool_calls_succeeded == 1 - assert loop.stats.tool_calls_failed == 0 - tool_messages = [msg for msg in loop.messages if msg.role == Role.tool] - assert len(tool_messages) == 1 - assert tool_messages[0].tool_call_id == "call-1" - - -def test_ask_user_question_tool_emits_assistant_question() -> None: - loop = _make_loop(enabled_tools=["ask_user_question"]) - started = _started( - "tool-task-question", - "AgentToolCallState", - { - "name": "ask_user_question", - "tool_call_id": "call-question", - "kwargs": { - "questions": [ - { - "question": "Which file type should I create?", - "options": [{"label": "Python"}, {"label": "JavaScript"}], - } - ] - }, - }, - ) - - events = loop._consume_workflow_event(started) - - assistant_event = next( - event for event in events if isinstance(event, AssistantEvent) - ) - tool_call_event = next( - event for event in events if isinstance(event, ToolCallEvent) - ) - assert assistant_event.content == "Which file type should I create?" - assert tool_call_event.tool_call_id == "call-question" - - -def test_ask_user_question_invalid_args_are_logged_and_ignored() -> None: - loop = _make_loop(enabled_tools=["ask_user_question"]) - started = _started( - "tool-task-question", - "AgentToolCallState", - { - "name": "ask_user_question", - "tool_call_id": "call-question", - "kwargs": {"questions": [{}]}, - }, - ) - - with patch( - "vibe.core.nuage.remote_workflow_event_translator.logger.warning" - ) as mock_warning: - events = loop._consume_workflow_event(started) - - assert any(isinstance(event, ToolCallEvent) for event in events) - assert not any(isinstance(event, AssistantEvent) for event in events) - mock_warning.assert_called_once() - - -def test_ask_user_question_wait_for_input_completion_emits_tool_result() -> None: - loop = _make_loop(enabled_tools=["ask_user_question"]) - ask_started = _started( - "tool-task-question", - "AgentToolCallState", - { - "name": "ask_user_question", - "tool_call_id": "call-question", - "kwargs": { - "questions": [{"question": "Which type of file?", "options": []}] - }, - }, - ) - wait_started = _started( - "wait-task-1", - "wait_for_input", - {"input_schema": {"title": "ChatInput"}, "label": "Which type of file?"}, - ) - wait_completed = _completed( - "wait-task-1", - "wait_for_input", - { - "input_schema": {"title": "ChatInput"}, - "label": "Which type of file?", - "input": {"message": [{"type": "text", "text": "Python"}]}, - }, - ) - - loop._consume_workflow_event(ask_started) - loop._consume_workflow_event(wait_started) - completed_events = loop._consume_workflow_event(wait_completed) - - tool_result = next( - (e for e in completed_events if isinstance(e, ToolResultEvent)), None - ) - assert tool_result is not None - assert tool_result.tool_call_id == "call-question" - user_message = next(e for e in completed_events if isinstance(e, UserMessageEvent)) - assert user_message.content == "Python" - - -def test_working_events_without_tool_call_id_render_remote_progress_row() -> None: - loop = _make_loop() - started = _started( - "working-1", - "working", - {"title": "Creating sandbox", "content": "initializing", "toolUIState": None}, - ) - completed = _completed( - "working-1", - "working", - { - "title": "Creating sandbox", - "content": "sandbox created", - "toolUIState": None, - }, - ) - - started_events = loop._consume_workflow_event(started) - completed_events = loop._consume_workflow_event(completed) - - assert started_events == [] - assert any(isinstance(event, ToolCallEvent) for event in completed_events) - assert any(isinstance(event, ToolStreamEvent) for event in completed_events) - result_event = next( - event for event in completed_events if isinstance(event, ToolResultEvent) - ) - assert result_event.tool_name == "Creating sandbox" - assert result_event.tool_call_id == "working-1" - - -def test_working_events_with_tool_call_id_wait_for_real_tool_call() -> None: - loop = _make_loop() - working_started = _started( - "working-tool-1", - "working", - { - "title": "Executing write_file", - "content": "writing file", - "toolUIState": {"toolCallId": "call-write"}, - }, - ) - tool_started = _started( - "tool-task-1", - "AgentToolCallState", - { - "name": "write_file", - "tool_call_id": "call-write", - "kwargs": { - "path": "hello_world.js", - "content": "console.log('Hello, World!');", - }, - }, - ) - - working_events = loop._consume_workflow_event(working_started) - tool_events = loop._consume_workflow_event(tool_started) - - assert any(isinstance(event, ToolCallEvent) for event in working_events) - assert any(isinstance(event, ToolStreamEvent) for event in working_events) - assert not any(isinstance(event, ToolCallEvent) for event in tool_events) - assert not any(isinstance(event, ToolResultEvent) for event in tool_events) - - -def test_working_task_promoted_to_real_tool_call_does_not_create_duplicate_row() -> ( - None -): - loop = _make_loop(enabled_tools=["write_file"]) - working_started = _started( - "working-tool-1", - "working", - { - "title": "Writing file", - "content": '# hello.py\n\nprint("Hello, World!")', - "toolUIState": None, - }, - ) - working_promoted = _completed( - "working-tool-1", - "working", - { - "title": "Executing write_file", - "content": "", - "toolUIState": { - "type": "file", - "toolCallId": "call-write", - "operations": [ - { - "type": "create", - "uri": "/workspace/hello.py", - "content": 'print("Hello, World!")', - } - ], - }, - }, - ) - agent_tool_completed = _completed( - "tool-task-1", - "AgentToolCallState", - { - "name": "write_file", - "tool_call_id": "call-write", - "kwargs": {"path": "hello.py", "content": 'print("Hello, World!")'}, - "output": { - "path": "/workspace/hello.py", - "bytes_written": 22, - "content": 'print("Hello, World!")', - }, - }, - ) - - assert loop._consume_workflow_event(working_started) == [] - - promoted_events = loop._consume_workflow_event(working_promoted) - assert len([e for e in promoted_events if isinstance(e, ToolCallEvent)]) == 1 - assert not any(isinstance(e, ToolStreamEvent) for e in promoted_events) - assert any(isinstance(e, ToolResultEvent) for e in promoted_events) - - completed_events = loop._consume_workflow_event(agent_tool_completed) - assert not any(isinstance(e, ToolCallEvent) for e in completed_events) - assert not any(isinstance(e, ToolResultEvent) for e in completed_events) - - -def test_idle_boundary_waits_for_open_tool_results() -> None: - loop = _make_loop(enabled_tools=["write_file"]) - working_started = _started( - "working-tool-1", - "working", - { - "title": "Executing write_file", - "content": "writing file", - "toolUIState": {"toolCallId": "call-write"}, - }, - ) - idle_candidate = _completed("input-task-1", "AgentInputState", {"input": None}) - tool_completed = _completed( - "tool-task-1", - "AgentToolCallState", - { - "name": "write_file", - "tool_call_id": "call-write", - "kwargs": { - "path": "hello_world.js", - "content": "console.log('Hello, World!');", - }, - "output": { - "path": "/workspace/hello_world.js", - "bytes_written": 29, - "content": "console.log('Hello, World!');", - }, - }, - ) - idle_after_tool = _completed("input-task-2", "AgentInputState", {"input": None}) - - working_events = loop._consume_workflow_event(working_started) - assert any(isinstance(event, ToolCallEvent) for event in working_events) - - loop._consume_workflow_event(idle_candidate) - assert loop._is_idle_boundary(idle_candidate) is False - - tool_events = loop._consume_workflow_event(tool_completed) - assert not any(isinstance(event, ToolCallEvent) for event in tool_events) - assert any(isinstance(event, ToolResultEvent) for event in tool_events) - - loop._consume_workflow_event(idle_after_tool) - assert loop._is_idle_boundary(idle_after_tool) is True - - -def test_send_user_message_tool_is_not_rendered() -> None: - loop = _make_loop() - started = _started( - "tool-task-send-user-message", - "AgentToolCallState", - { - "name": "send_user_message", - "tool_call_id": "call-send", - "kwargs": {"message": "hello"}, - }, - ) - completed = _completed( - "tool-task-send-user-message", - "AgentToolCallState", - { - "name": "send_user_message", - "tool_call_id": "call-send", - "kwargs": {"message": "hello"}, - "output": {"success": True, "error": None}, - }, - ) - - assert loop._consume_workflow_event(started) == [] - assert loop._consume_workflow_event(completed) == [] - - -def test_send_user_message_working_events_are_not_rendered() -> None: - loop = _make_loop() - started = _started( - "working-send-user-message", - "working", - { - "title": "Executing send_user_message", - "content": "Hello!", - "toolUIState": {"toolCallId": "call-send-working"}, - }, - ) - completed = _completed( - "working-send-user-message", - "working", - { - "title": "Executing send_user_message", - "content": "Hello!", - "toolUIState": {"toolCallId": "call-send-working"}, - }, - ) - - assert loop._consume_workflow_event(started) == [] - assert loop._consume_workflow_event(completed) == [] - - -def test_remote_bash_uses_known_tool_display_even_when_disabled_locally() -> None: - loop = _make_loop(enabled_tools=["write_file"]) - started = _started( - "tool-task-bash", - "AgentToolCallState", - { - "name": "bash", - "tool_call_id": "call-bash", - "kwargs": {"command": "cat hello.py | wc -c"}, - }, - ) - completed = _completed( - "tool-task-bash", - "AgentToolCallState", - { - "name": "bash", - "tool_call_id": "call-bash", - "kwargs": {"command": "cat hello.py | wc -c"}, - "output": { - "command": "cat hello.py | wc -c", - "stdout": "22\n", - "stderr": "", - "returncode": 0, - }, - }, - ) - - started_events = loop._consume_workflow_event(started) - completed_events = loop._consume_workflow_event(completed) - - tool_call_event = next( - event for event in started_events if isinstance(event, ToolCallEvent) - ) - result_event = next( - event for event in completed_events if isinstance(event, ToolResultEvent) - ) - - assert tool_call_event.tool_name == "bash" - assert tool_call_event.tool_class.get_name() == "bash" - assert tool_call_event.args is not None - assert tool_call_event.args.command == "cat hello.py | wc -c" # type: ignore[attr-defined] - assert result_event.result is not None - assert result_event.result.command == "cat hello.py | wc -c" # type: ignore[attr-defined] - assert result_event.result.stdout == "22\n" # type: ignore[attr-defined] - - -def test_canceled_tool_marks_cancelled_and_failed_stats() -> None: - loop = _make_loop(enabled_tools=["todo"]) - loop._task_state["tool-task-2"] = { - "name": "todo", - "tool_call_id": "call-2", - "kwargs": {"action": "read"}, - } - canceled = _canceled( - "tool-task-2", "AgentToolCallState", reason="user interrupted tool" - ) - - events = loop._consume_workflow_event(canceled) - - result_event = next(event for event in events if isinstance(event, ToolResultEvent)) - assert result_event.cancelled is True - assert result_event.error == "Canceled: user interrupted tool" - assert loop.stats.tool_calls_failed == 1 - assert loop.stats.tool_calls_succeeded == 0 - - -def test_working_thinking_type_emits_assistant_events() -> None: - loop = _make_loop() - started = _started( - "thinking-1", - "working", - {"type": "thinking", "title": "Thinking", "content": "", "toolUIState": None}, - ) - in_progress = _in_progress( - "thinking-1", "working", [JSONPatchAppend(path="/content", value="Hello!")] - ) - completed = _completed( - "thinking-1", - "working", - { - "type": "thinking", - "title": "Thinking", - "content": "Hello!", - "toolUIState": None, - }, - ) - - started_events = loop._consume_workflow_event(started) - progress_events = loop._consume_workflow_event(in_progress) - completed_events = loop._consume_workflow_event(completed) - - assert started_events == [] - assert len(progress_events) == 1 - assert isinstance(progress_events[0], ReasoningEvent) - assert progress_events[0].content == "Hello!" - assert completed_events == [] - - -def test_working_bash_progress_without_tool_call_id_streams_command_output() -> None: - loop = _make_loop(enabled_tools=["write_file"]) - started = _started( - "working-bash-1", - "working", - {"type": "tool", "title": "Planning", "content": "", "toolUIState": None}, - ) - in_progress = _in_progress( - "working-bash-1", - "working", - [ - JSONPatchAdd( - path="/toolUIState", - value={ - "type": "command", - "command": "ls -la /workspace", - "result": { - "status": "success", - "output": "total 4\ndrwxrwxrwx 2 root root 4096 Mar 20 10:18 .\ndrwxr-xr-x 1 root root 80 Mar 20 10:18 ..\n", - }, - }, - ), - JSONPatchReplace(path="/title", value="Executing bash"), - JSONPatchReplace(path="/content", value=""), - ], - ) - - started_events = loop._consume_workflow_event(started) - progress_events = loop._consume_workflow_event(in_progress) - - assert started_events == [] - tool_call_event = next( - event for event in progress_events if isinstance(event, ToolCallEvent) - ) - tool_stream_event = next( - event for event in progress_events if isinstance(event, ToolStreamEvent) - ) - - assert tool_call_event.tool_name == "bash" - assert tool_call_event.tool_class.get_name() == "bash" - assert tool_call_event.tool_call_id == "working-bash-1" - assert tool_stream_event.tool_name == "bash" - assert tool_stream_event.tool_call_id == "working-bash-1" - assert "command: ls -la /workspace" in tool_stream_event.message - assert "total 4" in tool_stream_event.message - assert "drwxrwxrwx 2 root root 4096" in tool_stream_event.message - - -def test_working_completed_with_tool_call_id_emits_tool_result() -> None: - loop = _make_loop(enabled_tools=["write_file"]) - working_started = _started( - "working-tool-1", - "working", - { - "title": "Executing write_file", - "content": "", - "toolUIState": {"toolCallId": "call-write-solo"}, - }, - ) - working_completed = _completed( - "working-tool-1", - "working", - { - "title": "Executing write_file", - "content": "", - "toolUIState": { - "type": "file", - "toolCallId": "call-write-solo", - "operations": [ - { - "type": "create", - "uri": "/workspace/hello.py", - "content": 'print("Hello, World!")', - } - ], - }, - }, - ) - - started_events = loop._consume_workflow_event(working_started) - assert any(isinstance(e, ToolCallEvent) for e in started_events) - - completed_events = loop._consume_workflow_event(working_completed) - result_events = [e for e in completed_events if isinstance(e, ToolResultEvent)] - assert len(result_events) == 1 - assert result_events[0].error is None - assert result_events[0].tool_call_id == "call-write-solo" - - -def test_working_completed_with_tool_call_id_emits_error_result() -> None: - loop = _make_loop(enabled_tools=["write_file"]) - working_started = _started( - "working-tool-2", - "working", - { - "title": "Executing write_file", - "content": "", - "toolUIState": {"toolCallId": "call-write-err"}, - }, - ) - working_completed = _completed( - "working-tool-2", - "working", - { - "title": "Executing write_file", - "content": "Error: Permission denied.", - "toolUIState": { - "type": "file", - "toolCallId": "call-write-err", - "operations": [], - }, - }, - ) - - loop._consume_workflow_event(working_started) - completed_events = loop._consume_workflow_event(working_completed) - - result_events = [e for e in completed_events if isinstance(e, ToolResultEvent)] - assert len(result_events) == 1 - assert result_events[0].error is not None - assert result_events[0].tool_call_id == "call-write-err" - - -def test_json_patch_with_array_index_preserves_list_structure() -> None: - loop = _make_loop() - started = _started( - "msg-1", - "assistant_message", - {"contentChunks": [{"type": "text", "text": "Hello"}]}, - ) - in_progress = _in_progress( - "msg-1", - "assistant_message", - [JSONPatchReplace(path="/contentChunks/0/text", value="Hello world")], - ) - - loop._consume_workflow_event(started) - progress_events = loop._consume_workflow_event(in_progress) - - assert len(progress_events) == 1 - assert isinstance(progress_events[0], AssistantEvent) - assert progress_events[0].content == " world" - - -def test_steer_input_events_are_suppressed() -> None: - loop = _make_loop() - steer_started = _started( - "steer-1", - "wait_for_input", - {"input_schema": {"title": "ChatInput"}, "label": "Send a message to steer..."}, - ) - steer_completed = _completed( - "steer-1", - "wait_for_input", - { - "input_schema": {"title": "ChatInput"}, - "label": "Send a message to steer...", - "input": None, - }, - ) - - assert loop._consume_workflow_event(steer_started) == [] - assert loop._translator.pending_input_request is not None - assert loop._translator.pending_input_request.task_id == "steer-1" - assert loop._consume_workflow_event(steer_completed) == [] - assert loop._translator.pending_input_request is None - - -def test_steer_input_allows_user_submission() -> None: - loop = _make_loop() - steer_started = _started( - "steer-1", - "wait_for_input", - {"input_schema": {"title": "ChatInput"}, "label": "Send a message to steer..."}, - ) - - assert loop._consume_workflow_event(steer_started) == [] - assert loop.is_waiting_for_input - - loop._translator.pending_input_request = None - - steer_completed = _completed( - "steer-1", - "wait_for_input", - { - "input_schema": {"title": "ChatInput"}, - "label": "Send a message to steer...", - "input": {"message": [{"type": "text", "text": "do X instead"}]}, - }, - ) - events = loop._consume_workflow_event(steer_completed) - assert any(isinstance(e, UserMessageEvent) for e in events) - user_event = next(e for e in events if isinstance(e, UserMessageEvent)) - assert user_event.content == "do X instead" - - -def test_steer_does_not_overwrite_regular_pending_input() -> None: - loop = _make_loop() - regular_started = _started( - "regular-1", - "wait_for_input", - {"input_schema": {"title": "ChatInput"}, "label": "Enter your message"}, - ) - loop._consume_workflow_event(regular_started) - assert loop._translator.pending_input_request is not None - assert loop._translator.pending_input_request.task_id == "regular-1" - - steer_started = _started( - "steer-1", - "wait_for_input", - {"input_schema": {"title": "ChatInput"}, "label": "Send a message to steer..."}, - ) - loop._consume_workflow_event(steer_started) - assert loop._translator.pending_input_request.task_id == "regular-1" - - -def test_invalid_steer_start_registers_task_for_terminal_handling() -> None: - loop = _make_loop() - steer_started = _started( - "steer-1", "wait_for_input", {"label": "Send a message to steer..."} - ) - - with patch("vibe.core.nuage.remote_events_source.logger.warning") as mock_warning: - assert loop._consume_workflow_event(steer_started) == [] - - mock_warning.assert_called_once() - assert "steer-1" in loop._translator._steer_task_ids - assert "steer-1" in loop._translator._invalid_steer_task_ids - assert loop._translator.pending_input_request is None - - -def test_invalid_steer_completion_does_not_clear_regular_prompt() -> None: - loop = _make_loop() - regular_started = _started( - "regular-1", - "wait_for_input", - {"input_schema": {"title": "ChatInput"}, "label": "Pick an option"}, - ) - loop._consume_workflow_event(regular_started) - - steer_started = _started( - "steer-1", "wait_for_input", {"label": "Send a message to steer..."} - ) - assert loop._consume_workflow_event(steer_started) == [] - assert loop._translator.pending_input_request is not None - assert loop._translator.pending_input_request.task_id == "regular-1" - - steer_completed = _completed( - "steer-1", - "wait_for_input", - {"label": "Send a message to steer...", "input": None}, - ) - events = loop._consume_workflow_event(steer_completed) - assert events == [] - assert loop._translator.pending_input_request is not None - assert loop._translator.pending_input_request.task_id == "regular-1" - - -def test_invalid_steer_cancellation_does_not_clear_regular_prompt() -> None: - loop = _make_loop() - regular_started = _started( - "regular-1", - "wait_for_input", - {"input_schema": {"title": "ChatInput"}, "label": "Pick an option"}, - ) - loop._consume_workflow_event(regular_started) - - steer_started = _started( - "steer-1", "wait_for_input", {"label": "Send a message to steer..."} - ) - assert loop._consume_workflow_event(steer_started) == [] - assert loop._translator.pending_input_request is not None - assert loop._translator.pending_input_request.task_id == "regular-1" - - events = loop._consume_workflow_event(_canceled("steer-1", "wait_for_input")) - assert events == [] - assert loop._translator.pending_input_request is not None - assert loop._translator.pending_input_request.task_id == "regular-1" - - -def test_steer_completion_is_preserved_while_regular_prompt_pending() -> None: - loop = _make_loop() - regular_started = _started( - "regular-1", - "wait_for_input", - {"input_schema": {"title": "ChatInput"}, "label": "Pick an option"}, - ) - loop._consume_workflow_event(regular_started) - - steer_started = _started( - "steer-1", - "wait_for_input", - {"input_schema": {"title": "ChatInput"}, "label": "Send a message to steer..."}, - ) - loop._consume_workflow_event(steer_started) - - steer_completed = _completed( - "steer-1", - "wait_for_input", - { - "input_schema": {"title": "ChatInput"}, - "label": "Send a message to steer...", - "input": {"message": [{"type": "text", "text": "user steer msg"}]}, - }, - ) - events = loop._consume_workflow_event(steer_completed) - - user_event = next(e for e in events if isinstance(e, UserMessageEvent)) - assert user_event.content == "user steer msg" - assert loop._translator.pending_input_request is not None - assert loop._translator.pending_input_request.task_id == "regular-1" diff --git a/tests/core/test_resolve_api_key.py b/tests/core/test_resolve_api_key.py new file mode 100644 index 0000000..0f94af2 --- /dev/null +++ b/tests/core/test_resolve_api_key.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import keyring +from keyring.errors import KeyringError +import pytest + +from tests.conftest import build_test_vibe_config +from vibe.core.config import MissingAPIKeyError, ProviderConfig, resolve_api_key +from vibe.core.llm.backend.mistral import MistralBackend +from vibe.core.types import Backend + + +def test_resolve_returns_env_value_without_consulting_keyring( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("CUSTOM_API_KEY", "env-key") + + def _fail(service: str, username: str) -> str | None: + raise AssertionError("keyring must not be consulted when env is set") + + monkeypatch.setattr(keyring, "get_password", _fail) + + assert resolve_api_key("CUSTOM_API_KEY") == "env-key" + + +def test_resolve_falls_back_to_keyring_when_env_unset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("CUSTOM_API_KEY", raising=False) + monkeypatch.setattr( + keyring, "get_password", lambda service, username: "keyring-key" + ) + + assert resolve_api_key("CUSTOM_API_KEY") == "keyring-key" + + +def test_resolve_returns_none_when_env_unset_and_keyring_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("CUSTOM_API_KEY", raising=False) + monkeypatch.setattr(keyring, "get_password", lambda service, username: None) + + assert resolve_api_key("CUSTOM_API_KEY") is None + + +def test_resolve_returns_none_when_keyring_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("CUSTOM_API_KEY", raising=False) + + def _unavailable(service: str, username: str) -> str | None: + raise KeyringError("no keyring") + + monkeypatch.setattr(keyring, "get_password", _unavailable) + + assert resolve_api_key("CUSTOM_API_KEY") is None + + +def test_resolve_returns_none_for_empty_env_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _fail(service: str, username: str) -> str | None: + raise AssertionError("keyring must not be consulted for an empty env key") + + monkeypatch.setattr(keyring, "get_password", _fail) + + assert resolve_api_key("") is None + + +def test_check_api_key_accepts_keyring_only_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + monkeypatch.setattr( + keyring, "get_password", lambda service, username: "keyring-key" + ) + + # Should not raise MissingAPIKeyError despite the env var being unset. + config = build_test_vibe_config() + + assert config.get_active_provider().api_key_env_var == "MISTRAL_API_KEY" + + +def test_check_api_key_raises_when_neither_env_nor_keyring( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + monkeypatch.setattr(keyring, "get_password", lambda service, username: None) + + with pytest.raises(MissingAPIKeyError): + build_test_vibe_config() + + +def test_mistral_backend_reads_keyring_only_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + monkeypatch.setattr( + keyring, "get_password", lambda service, username: "keyring-key" + ) + provider = ProviderConfig( + name="mistral", + api_base="https://api.mistral.ai/v1", + api_key_env_var="MISTRAL_API_KEY", + backend=Backend.MISTRAL, + ) + + backend = MistralBackend(provider) + + assert backend._api_key == "keyring-key" + + +def test_vibe_code_api_key_resolves_from_keyring( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + monkeypatch.setattr( + keyring, "get_password", lambda service, username: "keyring-key" + ) + + config = build_test_vibe_config() + + assert config.vibe_code_api_key == "keyring-key" + + +def test_vibe_code_api_key_empty_when_unresolved( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + monkeypatch.setattr( + keyring, "get_password", lambda service, username: "keyring-key" + ) + config = build_test_vibe_config() + + # Nothing resolves the key anymore; the property must return "" (not None). + monkeypatch.setattr(keyring, "get_password", lambda service, username: None) + + assert config.vibe_code_api_key == "" diff --git a/tests/core/test_telemetry_send.py b/tests/core/test_telemetry_send.py index 5ddbd47..8848e40 100644 --- a/tests/core/test_telemetry_send.py +++ b/tests/core/test_telemetry_send.py @@ -510,16 +510,6 @@ class TestTelemetryClient: "nb_session_messages": 4, } - def test_send_remote_resume_requested_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_remote_resume_requested(session_id="remote-123") - assert len(telemetry_events) == 1 - assert telemetry_events[0]["event_name"] == "vibe.remote_resume_requested" - assert telemetry_events[0]["properties"] == {"session_id": "remote-123"} - def test_send_teleport_failed_payload_includes_error_details( self, telemetry_events: list[dict[str, Any]] ) -> None: diff --git a/tests/core/test_teleport_nuage.py b/tests/core/test_teleport_nuage.py index 9089597..196251c 100644 --- a/tests/core/test_teleport_nuage.py +++ b/tests/core/test_teleport_nuage.py @@ -5,6 +5,7 @@ import json import httpx import pytest +from tests.constants import TELEPORT_SESSIONS_PATH from vibe.core.teleport.errors import ServiceTeleportError from vibe.core.teleport.nuage import ( NuageClient, @@ -52,7 +53,7 @@ async def test_start_posts_nuage_request() -> None: response = await nuage.start(_request()) assert seen_request is not None - assert str(seen_request.url) == "https://chat.example.com/api/v1/code/sessions" + assert str(seen_request.url) == f"https://chat.example.com{TELEPORT_SESSIONS_PATH}" assert seen_request.headers["authorization"] == "Bearer api-key" assert seen_request.headers["content-type"] == "application/json" assert json.loads(seen_request.content) == { diff --git a/tests/core/test_teleport_service.py b/tests/core/test_teleport_service.py index ab4dbc6..75cb99b 100644 --- a/tests/core/test_teleport_service.py +++ b/tests/core/test_teleport_service.py @@ -14,6 +14,7 @@ import pytest import zstandard from tests.conftest import build_test_vibe_config +from tests.constants import TELEPORT_COMPLETE_URL, TELEPORT_SESSIONS_PATH from vibe.core.teleport.errors import ( ServiceTeleportError, ServiceTeleportNotSupportedError, @@ -59,7 +60,7 @@ def _mock_handler() -> Any: "webSessionId": "web-session-id", "projectId": "project-id", "status": "running", - "url": "https://chat.example.com/code/project-id/web-session-id", + "url": TELEPORT_COMPLETE_URL, }, ) @@ -218,7 +219,7 @@ class TestTeleportServiceExecute: "webSessionId": "web-session-id", "projectId": "project-id", "status": "running", - "url": "https://chat.example.com/code/project-id/web-session-id", + "url": TELEPORT_COMPLETE_URL, }, ) @@ -247,10 +248,8 @@ class TestTeleportServiceExecute: assert isinstance(events[0], TeleportCheckingGitEvent) assert isinstance(events[1], TeleportStartingWorkflowEvent) assert isinstance(events[2], TeleportCompleteEvent) - assert ( - events[2].url == "https://chat.example.com/code/project-id/web-session-id" - ) - assert seen_url == "https://chat.example.com/api/v1/code/sessions" + assert events[2].url == TELEPORT_COMPLETE_URL + assert seen_url == f"https://chat.example.com{TELEPORT_SESSIONS_PATH}" assert seen_body is not None assert seen_body["message"] == { "role": "user", diff --git a/tests/core/test_text.py b/tests/core/test_text.py index f9688cc..097e5d6 100644 --- a/tests/core/test_text.py +++ b/tests/core/test_text.py @@ -1,6 +1,6 @@ from __future__ import annotations -from vibe.core.utils.text import snippet_start_line +from vibe.core.utils.text import snippet_start_line, snippet_start_lines class TestSnippetStartLine: @@ -27,3 +27,26 @@ class TestSnippetStartLine: def test_blank_snippet(self) -> None: assert snippet_start_line("hello", "\n") is None + + +class TestSnippetStartLines: + def test_single_occurrence(self) -> None: + assert snippet_start_lines("a\nb\nc", "b") == [2] + + def test_all_occurrences(self) -> None: + assert snippet_start_lines("x\ny\nx\nz\nx", "x") == [1, 3, 5] + + def test_repeated_on_same_line(self) -> None: + assert snippet_start_lines("x x x", "x") == [1, 1, 1] + + def test_non_overlapping(self) -> None: + assert snippet_start_lines("aaaa", "aa") == [1, 1] + + def test_multiline_snippet_occurrences(self) -> None: + assert snippet_start_lines("a\nb\nc\na\nb", "a\nb") == [1, 4] + + def test_not_found(self) -> None: + assert snippet_start_lines("hello\nworld", "missing") == [] + + def test_blank_snippet(self) -> None: + assert snippet_start_lines("hello", "\n") == [] diff --git a/tests/core/test_user_config_layer.py b/tests/core/test_user_config_layer.py index c7c8c80..f36c339 100644 --- a/tests/core/test_user_config_layer.py +++ b/tests/core/test_user_config_layer.py @@ -1,18 +1,31 @@ from __future__ import annotations +import os from pathlib import Path +import tomllib +from uuid import uuid4 import pytest -from vibe.core.config.layer import LayerImplementationError +from vibe.core.config.fingerprint import create_file_fingerprint +from vibe.core.config.layer import LayerImplementationError, LayerNotLoadedError from vibe.core.config.layers.user import UserConfigLayer -from vibe.core.config.patch import ConfigPatch +from vibe.core.config.patch import ( + AddOperationPatch, + ConfigPatch, + RemoveOperationPatch, + ReplaceOperationPatch, +) from vibe.core.config.types import MISSING_CONFIG_FILE_FINGERPRINT +def random_config_file_name() -> str: + return f"config-{uuid4().hex}.toml" + + @pytest.mark.asyncio async def test_reads_toml_file(tmp_working_directory: Path) -> None: - path = tmp_working_directory / "config.toml" + path = tmp_working_directory / random_config_file_name() path.write_text('active_model = "mistral-large"\ncount = 42\n') layer = UserConfigLayer(path=path, name="user-toml") @@ -25,7 +38,7 @@ async def test_reads_toml_file(tmp_working_directory: Path) -> None: @pytest.mark.asyncio async def test_always_trusted(tmp_working_directory: Path) -> None: - path = tmp_working_directory / "config.toml" + path = tmp_working_directory / random_config_file_name() path.write_text('key = "value"\n') layer = UserConfigLayer(path=path, name="user-toml") @@ -37,7 +50,7 @@ async def test_always_trusted(tmp_working_directory: Path) -> None: @pytest.mark.asyncio async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None: - path = tmp_working_directory / "nonexistent.toml" + path = tmp_working_directory / random_config_file_name() layer = UserConfigLayer(path=path, name="user-toml") data = await layer.load() assert data.model_extra == {} @@ -45,16 +58,241 @@ async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None: @pytest.mark.asyncio -async def test_apply_raises_not_implemented(tmp_working_directory: Path) -> None: - path = tmp_working_directory / "config.toml" +async def test_apply_raises_when_file_does_not_exist( + tmp_working_directory: Path, +) -> None: + path = tmp_working_directory / random_config_file_name() layer = UserConfigLayer(path=path, name="user-toml") - with pytest.raises(NotImplementedError, match="M2"): - await layer.apply(ConfigPatch(fingerprint="fp-1")) + + await layer.load() + assert layer.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT + + with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"): + await layer.apply( + ConfigPatch( + AddOperationPatch(path="/active_model", value="mistral-large"), + fingerprint=MISSING_CONFIG_FILE_FINGERPRINT, + ) + ) + + assert not path.exists() + + +@pytest.mark.asyncio +async def test_apply_sets_field_and_refreshes_cache( + tmp_working_directory: Path, +) -> None: + path = tmp_working_directory / random_config_file_name() + path.write_text("""\ +active_model = "old" + +[tools] +disabled_tools = ["bash", "python"] +deprecated_setting = true +""") + layer = UserConfigLayer(path=path, name="user-toml") + + await layer.load() + fingerprint = layer.fingerprint + assert isinstance(fingerprint, str) + + await layer.apply( + ConfigPatch( + ReplaceOperationPatch(path="/active_model", value="new"), + AddOperationPatch(path="/tools/enabled_tools", value=["read"]), + AddOperationPatch(path="/tools/disabled_tools/-", value="node"), + RemoveOperationPatch(path="/tools/disabled_tools/0"), + RemoveOperationPatch(path="/tools/deprecated_setting"), + fingerprint=fingerprint, + ) + ) + + expected_data = { + "active_model": "new", + "tools": {"disabled_tools": ["python", "node"], "enabled_tools": ["read"]}, + } + with path.open("rb") as file: + assert tomllib.load(file) == expected_data + + cached_data = layer._state.data + assert cached_data is not None + assert cached_data.model_extra == expected_data + assert layer.fingerprint != fingerprint + + +@pytest.mark.asyncio +async def test_apply_cache_fingerprint_matches_written_file( + tmp_working_directory: Path, +) -> None: + path = tmp_working_directory / random_config_file_name() + path.write_text("") + layer = UserConfigLayer(path=path, name="user-toml") + + await layer.load() + fingerprint = layer.fingerprint + assert isinstance(fingerprint, str) + + await layer.apply( + ConfigPatch( + AddOperationPatch(path="/active_model", value="mistral-large"), + fingerprint=fingerprint, + ) + ) + + with path.open("rb") as file: + assert layer.fingerprint == create_file_fingerprint(file) + + +@pytest.mark.asyncio +async def test_apply_uses_unique_temp_file(tmp_working_directory: Path) -> None: + path = tmp_working_directory / random_config_file_name() + fixed_tmp_path = tmp_working_directory / f".{path.name}.tmp" + path.write_text("") + fixed_tmp_path.write_text("stale") + layer = UserConfigLayer(path=path, name="user-toml") + + await layer.load() + fingerprint = layer.fingerprint + assert isinstance(fingerprint, str) + + await layer.apply( + ConfigPatch( + AddOperationPatch(path="/active_model", value="mistral-large"), + fingerprint=fingerprint, + ) + ) + + assert fixed_tmp_path.read_text() == "stale" + assert list(tmp_working_directory.glob(f".{path.name}.*.tmp")) == [] + with path.open("rb") as file: + assert tomllib.load(file) == {"active_model": "mistral-large"} + + +def test_atomic_replace_preserves_replacement_fingerprint( + tmp_working_directory: Path, +) -> None: + path = tmp_working_directory / random_config_file_name() + replacement = tmp_working_directory / f".{path.name}.tmp" + path.write_text("key = 1") + replacement.write_text("key = 2") + + with replacement.open("rb") as file: + replacement_fingerprint = create_file_fingerprint(file) + + os.replace(replacement, path) + + with path.open("rb") as file: + assert create_file_fingerprint(file) == replacement_fingerprint + + +@pytest.mark.asyncio +async def test_apply_raises_when_layer_is_not_loaded( + tmp_working_directory: Path, +) -> None: + path = tmp_working_directory / random_config_file_name() + layer = UserConfigLayer(path=path, name="user-toml") + + with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"): + await layer.apply( + ConfigPatch( + AddOperationPatch(path="/active_model", value="mistral-large"), + fingerprint=MISSING_CONFIG_FILE_FINGERPRINT, + ) + ) + + +@pytest.mark.asyncio +async def test_apply_raises_when_cache_is_invalidated( + tmp_working_directory: Path, +) -> None: + path = tmp_working_directory / random_config_file_name() + path.write_text('active_model = "old"\n') + layer = UserConfigLayer(path=path, name="user-toml") + + await layer.load() + fingerprint = layer.fingerprint + assert isinstance(fingerprint, str) + await layer.invalidate_cache() + + with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"): + await layer.apply( + ConfigPatch( + ReplaceOperationPatch(path="/active_model", value="new"), + fingerprint=fingerprint, + ) + ) + + +@pytest.mark.asyncio +async def test_apply_raises_when_parent_directory_does_not_exist( + tmp_working_directory: Path, +) -> None: + path = tmp_working_directory / "nested" / random_config_file_name() + layer = UserConfigLayer(path=path, name="user-toml") + + await layer.load() + + with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"): + await layer.apply( + ConfigPatch( + AddOperationPatch(path="/active_model", value="mistral-large"), + fingerprint=MISSING_CONFIG_FILE_FINGERPRINT, + ) + ) + + assert not path.exists() + + +@pytest.mark.asyncio +async def test_commit_sets_missing_nested_field(tmp_working_directory: Path) -> None: + path = tmp_working_directory / random_config_file_name() + path.write_text("[models]\n") + layer = UserConfigLayer(path=path, name="user-toml") + + await layer.load() + fingerprint = layer.fingerprint + assert isinstance(fingerprint, str) + + await layer.apply( + ConfigPatch( + AddOperationPatch(path="/models/active_model", value="mistral-large"), + fingerprint=fingerprint, + ) + ) + + with path.open("rb") as file: + assert tomllib.load(file) == {"models": {"active_model": "mistral-large"}} + + +@pytest.mark.asyncio +async def test_apply_overwrites_external_file_changes( + tmp_working_directory: Path, +) -> None: + path = tmp_working_directory / random_config_file_name() + path.write_text('active_model = "old"\n') + layer = UserConfigLayer(path=path, name="user-toml") + + await layer.load() + fingerprint = layer.fingerprint + assert isinstance(fingerprint, str) + path.write_text('active_model = "external"\n') + + await layer.apply( + ConfigPatch( + ReplaceOperationPatch(path="/active_model", value="new"), + fingerprint=fingerprint, + ) + ) + + with path.open("rb") as file: + assert tomllib.load(file) == {"active_model": "new"} + data = await layer.load() + assert data.model_extra == {"active_model": "new"} @pytest.mark.asyncio async def test_nested_toml_structure(tmp_working_directory: Path) -> None: - path = tmp_working_directory / "config.toml" + path = tmp_working_directory / random_config_file_name() path.write_text("""\ [models] active_model = "test" @@ -72,7 +310,7 @@ provider = "p" @pytest.mark.asyncio async def test_invalid_toml_raises(tmp_working_directory: Path) -> None: - path = tmp_working_directory / "bad.toml" + path = tmp_working_directory / random_config_file_name() path.write_text("this is not valid = = = toml [[[") layer = UserConfigLayer(path=path, name="user-toml") with pytest.raises(LayerImplementationError, match="_build_config_snapshot"): @@ -81,7 +319,7 @@ async def test_invalid_toml_raises(tmp_working_directory: Path) -> None: @pytest.mark.asyncio async def test_force_reload_reads_fresh_data(tmp_working_directory: Path) -> None: - path = tmp_working_directory / "config.toml" + path = tmp_working_directory / random_config_file_name() path.write_text('value = "first"\n') layer = UserConfigLayer(path=path, name="user-toml") @@ -107,7 +345,7 @@ async def test_force_reload_reads_fresh_data(tmp_working_directory: Path) -> Non @pytest.mark.asyncio async def test_empty_toml_file(tmp_working_directory: Path) -> None: - path = tmp_working_directory / "empty.toml" + path = tmp_working_directory / random_config_file_name() path.write_text("") layer = UserConfigLayer(path=path, name="user-toml") data = await layer.load() diff --git a/tests/core/test_user_display_content.py b/tests/core/test_user_display_content.py new file mode 100644 index 0000000..251680a --- /dev/null +++ b/tests/core/test_user_display_content.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import math +from types import SimpleNamespace + +from pydantic import ValidationError +import pytest + +from vibe.core.types import LLMMessage, Role, UserDisplayContentMetadata + + +def _metadata() -> UserDisplayContentMetadata: + return UserDisplayContentMetadata( + version="1.0.0", + host="mistral-vscode", + content=[ + {"type": "text", "text": "Look at "}, + { + "type": "workspace_mention", + "kind": "file", + "uri": "file:///repo/src/app.ts", + "name": "app.ts", + }, + ], + ) + + +def test_accepts_valid_metadata_and_preserves_host_owned_content() -> None: + metadata = UserDisplayContentMetadata.model_validate({ + "version": "1.0.0", + "host": "mistral-vscode", + "content": [ + {"type": "text", "text": "Look at "}, + { + "type": "workspace_mention", + "kind": "file", + "uri": "file:///repo/src/app.ts", + "name": "app.ts", + "automatic": False, + "nested": {"line": 12, "tags": ["source", None]}, + }, + ], + }) + + assert metadata.version == "1.0.0" + assert metadata.host == "mistral-vscode" + assert metadata.content == [ + {"type": "text", "text": "Look at "}, + { + "type": "workspace_mention", + "kind": "file", + "uri": "file:///repo/src/app.ts", + "name": "app.ts", + "automatic": False, + "nested": {"line": 12, "tags": ["source", None]}, + }, + ] + + +def test_strips_metadata_strings_without_stripping_host_owned_content() -> None: + metadata = UserDisplayContentMetadata.model_validate({ + "version": " 1.0.0 ", + "host": " mistral-vscode ", + "content": [{"type": "text", "text": " keep spaces "}], + }) + + assert metadata.version == "1.0.0" + assert metadata.host == "mistral-vscode" + assert metadata.content == [{"type": "text", "text": " keep spaces "}] + + +@pytest.mark.parametrize( + "payload", + [ + {"version": " ", "host": "mistral-vscode", "content": []}, + {"version": 2, "host": "mistral-vscode", "content": []}, + {"version": "1.0.0", "host": " ", "content": []}, + {"version": "1.0.0", "host": "mistral-vscode", "content": ["plain text"]}, + {"version": "1.0.0", "host": "mistral-vscode", "content": {"type": "text"}}, + { + "version": "1.0.0", + "host": "mistral-vscode", + "content": [{"type": "text"}], + "unexpected": True, + }, + { + "version": "1.0.0", + "host": "mistral-vscode", + "content": [{"type": "text", "value": object()}], + }, + { + "version": "1.0.0", + "host": "mistral-vscode", + "content": [{"type": "text", "value": math.nan}], + }, + ], +) +def test_rejects_invalid_metadata(payload: object) -> None: + with pytest.raises(ValidationError): + UserDisplayContentMetadata.model_validate(payload) + + +def test_llm_message_round_trips_user_display_content() -> None: + metadata = _metadata() + message = LLMMessage( + role=Role.user, content="Look at app.ts", user_display_content=metadata + ) + + dumped = message.model_dump(exclude_none=True, mode="json") + loaded = LLMMessage.model_validate(dumped) + + assert dumped["user_display_content"] == metadata.model_dump(mode="json") + assert loaded.user_display_content == metadata + + +def test_llm_message_keeps_old_sessions_without_user_display_content_valid() -> None: + message = LLMMessage.model_validate({"role": "user", "content": "hello"}) + + assert message.user_display_content is None + + +def test_llm_message_object_adapter_preserves_user_display_content() -> None: + metadata = _metadata() + + message = LLMMessage.model_validate( + SimpleNamespace( + role=Role.user, content="Look at app.ts", user_display_content=metadata + ) + ) + + assert message.user_display_content == metadata diff --git a/tests/core/test_vibe_config_schema.py b/tests/core/test_vibe_config_schema.py index 73fd67b..ddb0078 100644 --- a/tests/core/test_vibe_config_schema.py +++ b/tests/core/test_vibe_config_schema.py @@ -27,6 +27,7 @@ async def test_full_toml_to_vibe_config_schema(tmp_path: Path) -> None: """\ vim_keybindings = true api_timeout = 300.0 +api_retry_max_elapsed_time = 120.0 active_model = "codestral" disabled_tools = ["bash"] default_agent = "plan" @@ -54,6 +55,7 @@ provider = "mistral" assert config.vim_keybindings is True assert config.api_timeout == 300.0 + assert config.api_retry_max_elapsed_time == 120.0 assert config.active_model == "codestral" assert config.models[0].alias == "codestral" assert "bash" in config.disabled_tools diff --git a/tests/core/tools/builtins/test_edit.py b/tests/core/tools/builtins/test_edit.py index 3fc6f5b..0a051d5 100644 --- a/tests/core/tools/builtins/test_edit.py +++ b/tests/core/tools/builtins/test_edit.py @@ -230,21 +230,21 @@ def test_get_result_display() -> None: assert "foo.py" in display.message -def test_ui_start_line_not_part_of_model_contract() -> None: +def test_ui_start_lines_not_part_of_model_contract() -> None: result = EditResult(file="/x", message="m", old_string="a", new_string="b") - result._ui_start_line = 42 + result._ui_start_lines = [42] - assert result.ui_start_line == 42 - assert "ui_start_line" not in result.model_dump() - assert "_ui_start_line" not in result.model_dump() - assert "ui_start_line" not in result.model_dump_json() - assert "ui_start_line" not in EditResult.model_fields - assert "ui_start_line" not in EditResult.model_json_schema().get("properties", {}) - assert "ui_start_line" not in dict(result) + assert result.ui_start_lines == [42] + assert "ui_start_lines" not in result.model_dump() + assert "_ui_start_lines" not in result.model_dump() + assert "ui_start_lines" not in result.model_dump_json() + assert "ui_start_lines" not in EditResult.model_fields + assert "ui_start_lines" not in EditResult.model_json_schema().get("properties", {}) + assert "ui_start_lines" not in dict(result) @pytest.mark.asyncio -async def test_ui_start_line_computed_at_edit_site( +async def test_ui_start_lines_computed_at_edit_site( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) @@ -255,11 +255,11 @@ async def test_ui_start_line_computed_at_edit_site( edit.run(EditArgs(file_path="f.txt", old_string="gamma", new_string="GAMMA")) ) - assert result.ui_start_line == 3 + assert result.ui_start_lines == [3] @pytest.mark.asyncio -async def test_ui_start_line_set_for_pure_deletion( +async def test_ui_start_lines_set_for_pure_deletion( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) @@ -270,4 +270,38 @@ async def test_ui_start_line_set_for_pure_deletion( edit.run(EditArgs(file_path="f.txt", old_string="remove\n", new_string="")) ) - assert result.ui_start_line == 3 + assert result.ui_start_lines == [3] + + +@pytest.mark.asyncio +async def test_ui_start_lines_lists_all_occurrences_for_replace_all( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + _write(tmp_path, "f.txt", "x\ntgt\ny\ntgt\nz\ntgt\n") + edit = _make_edit() + + result = await collect_result( + edit.run( + EditArgs( + file_path="f.txt", old_string="tgt", new_string="TGT", replace_all=True + ) + ) + ) + + assert result.ui_start_lines == [2, 4, 6] + + +@pytest.mark.asyncio +async def test_ui_start_lines_single_entry_without_replace_all( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + _write(tmp_path, "f.txt", "a\nuniq\nb\n") + edit = _make_edit() + + result = await collect_result( + edit.run(EditArgs(file_path="f.txt", old_string="uniq", new_string="UNIQ")) + ) + + assert result.ui_start_lines == [2] diff --git a/tests/e2e/agent_loop_characterization/support.py b/tests/e2e/agent_loop_characterization/support.py new file mode 100644 index 0000000..62eaa9c --- /dev/null +++ b/tests/e2e/agent_loop_characterization/support.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from collections.abc import Callable +import io +import json +from pathlib import Path +import time +import tomllib +from typing import Any + +import pexpect +import tomli_w + +from tests.e2e.common import strip_ansi, wait_for_rendered_text +from tests.e2e.mock_server import ChatCompletionsRequestPayload, StreamingMockServer + +APPROVAL_INPUT_GRACE_PERIOD_S = 0.65 + + +def assistant_text_chunks(text: str, *, created: int = 100) -> list[dict[str, object]]: + return [ + StreamingMockServer.build_chunk( + created=created, + delta={"role": "assistant", "content": text}, + finish_reason=None, + ), + StreamingMockServer.build_chunk( + created=created + 1, + delta={}, + finish_reason="stop", + usage={"prompt_tokens": 3, "completion_tokens": 4}, + ), + ] + + +def single_tool_call_chunks( + *, call_id: str, tool_name: str, arguments: dict[str, Any], created: int = 10 +) -> list[dict[str, object]]: + return [ + StreamingMockServer.build_chunk( + created=created, + delta=StreamingMockServer.build_tool_call_delta( + call_id=call_id, + tool_name=tool_name, + arguments=json.dumps(arguments, separators=(",", ":")), + ), + finish_reason=None, + ), + StreamingMockServer.build_chunk( + created=created + 1, + delta={}, + finish_reason="tool_calls", + usage={"prompt_tokens": 3, "completion_tokens": 4}, + ), + ] + + +def multi_tool_call_chunks( + tool_calls: list[tuple[str, str, dict[str, Any]]], *, created: int = 10 +) -> list[dict[str, object]]: + return [ + StreamingMockServer.build_chunk( + created=created, + delta={ + "role": "assistant", + "tool_calls": [ + { + "index": index, + "id": call_id, + "type": "function", + "function": { + "name": tool_name, + "arguments": json.dumps(arguments, separators=(",", ":")), + }, + } + for index, (call_id, tool_name, arguments) in enumerate(tool_calls) + ], + }, + finish_reason=None, + ), + StreamingMockServer.build_chunk( + created=created + 1, + delta={}, + finish_reason="tool_calls", + usage={"prompt_tokens": 3, "completion_tokens": 4}, + ), + ] + + +def _messages(payload: ChatCompletionsRequestPayload) -> list[dict[str, Any]]: + raw_messages = payload.get("messages") + assert raw_messages is not None + return [dict(message) for message in raw_messages] + + +def assert_tool_result_contains( + payload: ChatCompletionsRequestPayload, *, call_id: str, expected: str +) -> None: + matching_messages = [ + message + for message in _messages(payload) + if message.get("role") == "tool" and message.get("tool_call_id") == call_id + ] + assert len(matching_messages) == 1 + content = matching_messages[0].get("content") + assert isinstance(content, str) + assert expected in content, content + + +def assert_assistant_tool_call_present( + payload: ChatCompletionsRequestPayload, *, call_id: str, tool_name: str +) -> None: + for message in _messages(payload): + if message.get("role") != "assistant": + continue + tool_calls = message.get("tool_calls") + if not isinstance(tool_calls, list): + continue + for tool_call in tool_calls: + if not isinstance(tool_call, dict): + continue + function = tool_call.get("function") + if not isinstance(function, dict): + continue + if tool_call.get("id") == call_id and function.get("name") == tool_name: + return + raise AssertionError(f"Tool call {call_id!r} for {tool_name!r} was not present.") + + +def assert_message_content_present( + payload: ChatCompletionsRequestPayload, *, role: str, expected: str +) -> None: + assert any( + message.get("role") == role and expected in str(message.get("content", "")) + for message in _messages(payload) + ) + + +def wait_for_request_count_while_draining_child_output( + child: pexpect.spawn, + captured: io.StringIO, + request_count_getter: Callable[[], int], + *, + expected_count: int, + timeout: float, +) -> None: + start = time.monotonic() + while time.monotonic() - start < timeout: + if request_count_getter() >= expected_count: + return + try: + child.expect(r"\S", timeout=0.05) + except pexpect.TIMEOUT: + pass + rendered_tail = strip_ansi(captured.getvalue())[-1200:] + raise AssertionError( + f"Timed out waiting for {expected_count} backend request(s).\n\n" + f"Rendered tail:\n{rendered_tail}" + ) + + +def answer_approval( + child: pexpect.spawn, captured: io.StringIO, *, tool_name: str, key: str +) -> None: + wait_for_rendered_text( + child, captured, needle=f"Permission for the {tool_name} tool", timeout=10 + ) + wait_for_rendered_text(child, captured, needle="Deny", timeout=10) + time.sleep(APPROVAL_INPUT_GRACE_PERIOD_S) + child.send(key) + child.send("\r") + + +def set_tool_denylist(vibe_home: Path, tool_name: str, patterns: list[str]) -> None: + config_path = vibe_home / "config.toml" + config = tomllib.loads(config_path.read_text(encoding="utf-8")) + config.setdefault("tools", {}).setdefault(tool_name, {})["denylist"] = patterns + config_path.write_bytes(tomli_w.dumps(config).encode()) diff --git a/tests/e2e/agent_loop_characterization/test_resume.py b/tests/e2e/agent_loop_characterization/test_resume.py new file mode 100644 index 0000000..73190da --- /dev/null +++ b/tests/e2e/agent_loop_characterization/test_resume.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import time + +import pexpect +import pytest + +from tests.e2e.agent_loop_characterization.support import ( + assert_assistant_tool_call_present, + assert_message_content_present, + assert_tool_result_contains, + assistant_text_chunks, + single_tool_call_chunks, + wait_for_request_count_while_draining_child_output, +) +from tests.e2e.common import ( + SpawnedVibeProcessFixture, + send_ctrl_c_until_quit_confirmation, + strip_ansi, + wait_for_main_screen, + wait_for_rendered_text, +) +from tests.e2e.mock_server import ChatCompletionsRequestPayload, StreamingMockServer + +RESUME_TODO_CALL_ID = "call_todo_resume" +RESUME_INITIAL_PROMPT = "Start tool history" +RESUME_CONTINUE_PROMPT = "Continue from prior tool history" +RESUME_FIRST_TURN_RESPONSE = "First turn with todo complete." +RESUME_RESUMED_TURN_RESPONSE = "Resumed turn saw prior tool history." +RESUME_TODO_RESULT_TEXT = "Updated 1 todos" + + +def _load_latest_session_messages() -> list[dict[str, object]]: + session_root = Path(os.environ["VIBE_HOME"]) / "logs" / "session" + messages_paths = list(session_root.glob("session_*/messages.jsonl")) + if not messages_paths: + return [] + + messages_path = max(messages_paths, key=lambda path: path.stat().st_mtime) + messages: list[dict[str, object]] = [] + try: + for line in messages_path.read_text(encoding="utf-8").splitlines(): + messages.append(json.loads(line)) + except (OSError, json.JSONDecodeError): + return [] + return messages + + +def _has_message_content( + messages: list[dict[str, object]], *, role: str, expected: str +) -> bool: + return any( + message.get("role") == role and expected in str(message.get("content", "")) + for message in messages + ) + + +def _has_assistant_tool_call( + messages: list[dict[str, object]], *, call_id: str +) -> bool: + return any( + message.get("role") == "assistant" + and call_id in json.dumps(message.get("tool_calls", [])) + for message in messages + ) + + +def _has_tool_result( + messages: list[dict[str, object]], *, call_id: str, expected: str +) -> bool: + return any( + message.get("role") == "tool" + and message.get("tool_call_id") == call_id + and expected in str(message.get("content", "")) + for message in messages + ) + + +def _wait_for_persisted_resume_history( + *, + user_prompt: str, + assistant_tool_call_id: str, + tool_result_text: str, + final_assistant_text: str, + timeout: float, +) -> None: + start = time.monotonic() + while time.monotonic() - start < timeout: + messages = _load_latest_session_messages() + if ( + _has_message_content(messages, role="user", expected=user_prompt) + and _has_assistant_tool_call(messages, call_id=assistant_tool_call_id) + and _has_tool_result( + messages, call_id=assistant_tool_call_id, expected=tool_result_text + ) + and _has_message_content( + messages, role="assistant", expected=final_assistant_text + ) + ): + return + time.sleep(0.05) + + persisted_roles = [ + message.get("role") for message in _load_latest_session_messages() + ] + raise AssertionError( + f"Timed out waiting for persisted resume history. Persisted roles: {persisted_roles}" + ) + + +def _resume_tool_history_factory( + request_index: int, _payload: ChatCompletionsRequestPayload +) -> list[dict[str, object]]: + if request_index == 0: + return single_tool_call_chunks( + call_id=RESUME_TODO_CALL_ID, + tool_name="todo", + arguments={ + "action": "write", + "todos": [ + { + "id": "resume-history", + "content": "preserve tool history", + "status": "completed", + "priority": "high", + } + ], + }, + created=40, + ) + if request_index == 1: + return assistant_text_chunks(RESUME_FIRST_TURN_RESPONSE, created=50) + + return assistant_text_chunks(RESUME_RESUMED_TURN_RESPONSE, created=60) + + +@pytest.mark.timeout(35) +@pytest.mark.parametrize( + "streaming_mock_server", + [pytest.param(_resume_tool_history_factory, id="resume-tool-history")], + indirect=True, +) +def test_resumed_session_sends_prior_tool_call_and_result_history_to_the_model( + 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(RESUME_INITIAL_PROMPT) + child.send("\r") + + wait_for_request_count_while_draining_child_output( + child, + captured, + lambda: len(streaming_mock_server.requests), + expected_count=2, + timeout=10, + ) + wait_for_rendered_text( + child, captured, needle=RESUME_FIRST_TURN_RESPONSE, timeout=10 + ) + _wait_for_persisted_resume_history( + user_prompt=RESUME_INITIAL_PROMPT, + assistant_tool_call_id=RESUME_TODO_CALL_ID, + tool_result_text=RESUME_TODO_RESULT_TEXT, + final_assistant_text=RESUME_FIRST_TURN_RESPONSE, + timeout=10, + ) + + send_ctrl_c_until_quit_confirmation(child, captured, timeout=5) + 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) + + 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(RESUME_CONTINUE_PROMPT) + resumed_child.send("\r") + + wait_for_request_count_while_draining_child_output( + resumed_child, + resumed_captured, + lambda: len(streaming_mock_server.requests), + expected_count=3, + timeout=10, + ) + wait_for_rendered_text( + resumed_child, + resumed_captured, + needle=RESUME_RESUMED_TURN_RESPONSE, + timeout=10, + ) + + send_ctrl_c_until_quit_confirmation(resumed_child, resumed_captured, timeout=5) + resumed_child.expect(pexpect.EOF, timeout=10) + + resumed_payload = streaming_mock_server.requests[2] + assert_message_content_present( + resumed_payload, role="user", expected=RESUME_INITIAL_PROMPT + ) + assert_assistant_tool_call_present( + resumed_payload, call_id=RESUME_TODO_CALL_ID, tool_name="todo" + ) + assert_tool_result_contains( + resumed_payload, call_id=RESUME_TODO_CALL_ID, expected=RESUME_TODO_RESULT_TEXT + ) + assert_message_content_present( + resumed_payload, role="assistant", expected=RESUME_FIRST_TURN_RESPONSE + ) + assert_message_content_present( + resumed_payload, role="user", expected=RESUME_CONTINUE_PROMPT + ) diff --git a/tests/e2e/agent_loop_characterization/test_subagents.py b/tests/e2e/agent_loop_characterization/test_subagents.py new file mode 100644 index 0000000..596d02b --- /dev/null +++ b/tests/e2e/agent_loop_characterization/test_subagents.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from pathlib import Path + +import pexpect +import pytest + +from tests.e2e.agent_loop_characterization.support import ( + assert_message_content_present, + assert_tool_result_contains, + assistant_text_chunks, + single_tool_call_chunks, + wait_for_request_count_while_draining_child_output, +) +from tests.e2e.common import ( + SpawnedVibeProcessFixture, + send_ctrl_c_until_quit_confirmation, + wait_for_main_screen, + wait_for_rendered_text, +) +from tests.e2e.mock_server import ChatCompletionsRequestPayload, StreamingMockServer + +SUBAGENT_TOOL_CALL_ID = "call_subagent_explore" +SUBAGENT_MARKER = "__E2E_SUBAGENT_DONE__" + + +def _explore_subagent_factory( + request_index: int, _payload: ChatCompletionsRequestPayload +) -> list[dict[str, object]]: + if request_index == 0: + return single_tool_call_chunks( + call_id=SUBAGENT_TOOL_CALL_ID, + tool_name="task", + arguments={ + "agent": "explore", + "task": f"Find the marker {SUBAGENT_MARKER}.", + }, + created=90, + ) + if request_index == 1: + return assistant_text_chunks(f"Subagent found {SUBAGENT_MARKER}.", created=100) + + return assistant_text_chunks("Parent used the subagent result.", created=110) + + +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "streaming_mock_server", + [pytest.param(_explore_subagent_factory, id="explore-subagent")], + indirect=True, +) +def test_explore_subagent_returns_result_to_parent_model( + 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("Delegate exploration") + child.send("\r") + + wait_for_request_count_while_draining_child_output( + child, + captured, + lambda: len(streaming_mock_server.requests), + expected_count=3, + timeout=15, + ) + wait_for_rendered_text( + child, captured, needle="Parent used the subagent result.", timeout=10 + ) + + send_ctrl_c_until_quit_confirmation(child, captured, timeout=5) + child.expect(pexpect.EOF, timeout=10) + + assert streaming_mock_server.requests[1].get("stream") is not True + assert_message_content_present( + streaming_mock_server.requests[1], + role="user", + expected=f"Find the marker {SUBAGENT_MARKER}.", + ) + assert_tool_result_contains( + streaming_mock_server.requests[2], + call_id=SUBAGENT_TOOL_CALL_ID, + expected=f"Subagent found {SUBAGENT_MARKER}.", + ) diff --git a/tests/e2e/agent_loop_characterization/test_tool_execution.py b/tests/e2e/agent_loop_characterization/test_tool_execution.py new file mode 100644 index 0000000..5c6c29d --- /dev/null +++ b/tests/e2e/agent_loop_characterization/test_tool_execution.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pexpect +import pytest + +from tests.e2e.agent_loop_characterization.support import ( + answer_approval, + assert_assistant_tool_call_present, + assert_tool_result_contains, + assistant_text_chunks, + multi_tool_call_chunks, + set_tool_denylist, + single_tool_call_chunks, + wait_for_request_count_while_draining_child_output, +) +from tests.e2e.common import ( + SpawnedVibeProcessFixture, + send_ctrl_c_until_quit_confirmation, + wait_for_main_screen, + wait_for_rendered_text, + wait_for_request_count, +) +from tests.e2e.mock_server import ChatCompletionsRequestPayload, StreamingMockServer + +DENIED_BASH_CALL_ID = "call_bash_denied" +DENIED_BASH_FILE = "denied-side-effect.txt" +FAILING_BASH_CALL_ID = "call_bash_fails" +FAILING_BASH_STDERR = "__E2E_BASH_FAILURE__" +MULTI_TOOL_FIRST_CALL_ID = "call_todo_multi_1" +MULTI_TOOL_SECOND_CALL_ID = "call_todo_multi_2" + + +def _denied_bash_factory( + request_index: int, _payload: ChatCompletionsRequestPayload +) -> list[dict[str, object]]: + if request_index == 0: + return single_tool_call_chunks( + call_id=DENIED_BASH_CALL_ID, + tool_name="bash", + arguments={"command": f"touch {DENIED_BASH_FILE}"}, + ) + + return assistant_text_chunks("Denied without creating the file.", created=20) + + +def _failing_bash_factory( + request_index: int, _payload: ChatCompletionsRequestPayload +) -> list[dict[str, object]]: + if request_index == 0: + return single_tool_call_chunks( + call_id=FAILING_BASH_CALL_ID, + tool_name="bash", + arguments={"command": f"printf {FAILING_BASH_STDERR} >&2; false"}, + ) + + return assistant_text_chunks("Recovered after the shell failure.", created=30) + + +def _multi_tool_turn_factory( + request_index: int, _payload: ChatCompletionsRequestPayload +) -> list[dict[str, object]]: + if request_index == 0: + return multi_tool_call_chunks( + [ + (MULTI_TOOL_FIRST_CALL_ID, "todo", {"action": "read"}), + (MULTI_TOOL_SECOND_CALL_ID, "todo", {"action": "read"}), + ], + created=160, + ) + + return assistant_text_chunks("Both todo reads completed.", created=170) + + +@pytest.mark.timeout(25) +@pytest.mark.parametrize( + "streaming_mock_server", + [pytest.param(_denied_bash_factory, id="denied-bash-tool")], + indirect=True, +) +def test_denylisted_bash_tool_does_not_run_and_is_reported_to_the_model( + streaming_mock_server: StreamingMockServer, + setup_e2e_env: None, + e2e_workdir: Path, + spawned_vibe_process: SpawnedVibeProcessFixture, +) -> None: + set_tool_denylist(Path(os.environ["VIBE_HOME"]), "bash", ["touch"]) + denied_path = e2e_workdir / DENIED_BASH_FILE + + with spawned_vibe_process(e2e_workdir) as (child, captured): + wait_for_main_screen(child, timeout=15) + child.send("Try a denied shell command") + child.send("\r") + + wait_for_request_count( + lambda: len(streaming_mock_server.requests), expected_count=1, timeout=10 + ) + wait_for_request_count_while_draining_child_output( + child, + captured, + lambda: len(streaming_mock_server.requests), + expected_count=2, + timeout=10, + ) + wait_for_rendered_text( + child, captured, needle="Denied without creating the file.", timeout=10 + ) + + send_ctrl_c_until_quit_confirmation(child, captured, timeout=5) + child.expect(pexpect.EOF, timeout=10) + + assert not denied_path.exists() + assert_tool_result_contains( + streaming_mock_server.requests[1], + call_id=DENIED_BASH_CALL_ID, + expected="Command denied:", + ) + + +@pytest.mark.timeout(25) +@pytest.mark.parametrize( + "streaming_mock_server", + [pytest.param(_failing_bash_factory, id="failing-bash-tool")], + indirect=True, +) +def test_failed_bash_tool_result_is_reported_to_the_model_and_turn_recovers( + 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("Run a failing shell command") + child.send("\r") + + wait_for_request_count( + lambda: len(streaming_mock_server.requests), expected_count=1, timeout=10 + ) + answer_approval(child, captured, tool_name="bash", key="y") + wait_for_request_count_while_draining_child_output( + child, + captured, + lambda: len(streaming_mock_server.requests), + expected_count=2, + timeout=10, + ) + wait_for_rendered_text( + child, captured, needle="Recovered after the shell failure.", timeout=10 + ) + + send_ctrl_c_until_quit_confirmation(child, captured, timeout=5) + child.expect(pexpect.EOF, timeout=10) + + assert_tool_result_contains( + streaming_mock_server.requests[1], + call_id=FAILING_BASH_CALL_ID, + expected="Return code: 1", + ) + assert_tool_result_contains( + streaming_mock_server.requests[1], + call_id=FAILING_BASH_CALL_ID, + expected=FAILING_BASH_STDERR, + ) + + +@pytest.mark.timeout(25) +@pytest.mark.parametrize( + "streaming_mock_server", + [pytest.param(_multi_tool_turn_factory, id="multi-tool-turn")], + indirect=True, +) +def test_multiple_tool_calls_in_one_assistant_turn_return_distinct_results( + 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("Run two todo reads") + child.send("\r") + + wait_for_request_count_while_draining_child_output( + child, + captured, + lambda: len(streaming_mock_server.requests), + expected_count=2, + timeout=10, + ) + wait_for_rendered_text( + child, captured, needle="Both todo reads completed.", timeout=10 + ) + + send_ctrl_c_until_quit_confirmation(child, captured, timeout=5) + child.expect(pexpect.EOF, timeout=10) + + assert_assistant_tool_call_present( + streaming_mock_server.requests[1], + call_id=MULTI_TOOL_FIRST_CALL_ID, + tool_name="todo", + ) + assert_assistant_tool_call_present( + streaming_mock_server.requests[1], + call_id=MULTI_TOOL_SECOND_CALL_ID, + tool_name="todo", + ) + assert_tool_result_contains( + streaming_mock_server.requests[1], + call_id=MULTI_TOOL_FIRST_CALL_ID, + expected="Retrieved 0 todos", + ) + assert_tool_result_contains( + streaming_mock_server.requests[1], + call_id=MULTI_TOOL_SECOND_CALL_ID, + expected="Retrieved 0 todos", + ) diff --git a/tests/e2e/agent_loop_characterization/test_tool_permissions.py b/tests/e2e/agent_loop_characterization/test_tool_permissions.py new file mode 100644 index 0000000..cd0b639 --- /dev/null +++ b/tests/e2e/agent_loop_characterization/test_tool_permissions.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pexpect +import pytest + +from tests.e2e.agent_loop_characterization.support import ( + answer_approval, + assert_tool_result_contains, + assistant_text_chunks, + set_tool_denylist, + single_tool_call_chunks, + wait_for_request_count_while_draining_child_output, +) +from tests.e2e.common import ( + SpawnedVibeProcessFixture, + send_ctrl_c_until_quit_confirmation, + wait_for_main_screen, + wait_for_rendered_text, + wait_for_request_count, +) +from tests.e2e.mock_server import ChatCompletionsRequestPayload, StreamingMockServer + +WRITE_APPROVED_CALL_ID = "call_write_approved" +WRITE_REJECTED_CALL_ID = "call_write_rejected" +APPROVED_FILE = "approved-write.txt" +REJECTED_FILE = "rejected-write.txt" +SESSION_PERMISSION_FIRST_CALL_ID = "call_bash_session_1" +SESSION_PERMISSION_SECOND_CALL_ID = "call_bash_session_2" +SESSION_PERMISSION_OUTPUT = "__E2E_SESSION_PERMISSION__" + + +def _write_file_factory( + request_index: int, _payload: ChatCompletionsRequestPayload +) -> list[dict[str, object]]: + if request_index == 0: + return single_tool_call_chunks( + call_id=WRITE_APPROVED_CALL_ID, + tool_name="write_file", + arguments={"path": APPROVED_FILE, "content": "approved content\n"}, + created=120, + ) + if request_index == 1: + return assistant_text_chunks("Approved file was written.", created=130) + if request_index == 2: + return single_tool_call_chunks( + call_id=WRITE_REJECTED_CALL_ID, + tool_name="write_file", + arguments={"path": REJECTED_FILE, "content": "rejected content\n"}, + created=140, + ) + + return assistant_text_chunks("Rejected file was not written.", created=150) + + +def _session_permission_factory( + request_index: int, _payload: ChatCompletionsRequestPayload +) -> list[dict[str, object]]: + arguments = {"command": f'printf "{SESSION_PERMISSION_OUTPUT}\\n"'} + if request_index == 0: + return single_tool_call_chunks( + call_id=SESSION_PERMISSION_FIRST_CALL_ID, + tool_name="bash", + arguments=arguments, + created=180, + ) + if request_index == 1: + return assistant_text_chunks("First command completed.", created=190) + if request_index == 2: + return single_tool_call_chunks( + call_id=SESSION_PERMISSION_SECOND_CALL_ID, + tool_name="bash", + arguments=arguments, + created=200, + ) + + return assistant_text_chunks( + "Second command reused session permission.", created=210 + ) + + +@pytest.mark.timeout(35) +@pytest.mark.parametrize( + "streaming_mock_server", + [pytest.param(_write_file_factory, id="write-file-approval-and-rejection")], + indirect=True, +) +def test_write_file_approval_creates_file_and_rejection_leaves_file_absent( + streaming_mock_server: StreamingMockServer, + setup_e2e_env: None, + e2e_workdir: Path, + spawned_vibe_process: SpawnedVibeProcessFixture, +) -> None: + set_tool_denylist( + Path(os.environ["VIBE_HOME"]), + "write_file", + [str((e2e_workdir / REJECTED_FILE).resolve())], + ) + approved_path = e2e_workdir / APPROVED_FILE + rejected_path = e2e_workdir / REJECTED_FILE + + with spawned_vibe_process(e2e_workdir) as (child, captured): + wait_for_main_screen(child, timeout=15) + child.send("Create the approved file") + child.send("\r") + + wait_for_request_count( + lambda: len(streaming_mock_server.requests), expected_count=1, timeout=10 + ) + answer_approval(child, captured, tool_name="write_file", key="y") + wait_for_request_count_while_draining_child_output( + child, + captured, + lambda: len(streaming_mock_server.requests), + expected_count=2, + timeout=10, + ) + wait_for_rendered_text( + child, captured, needle="Approved file was written.", timeout=10 + ) + + child.send("Try the rejected file") + child.send("\r") + wait_for_request_count_while_draining_child_output( + child, + captured, + lambda: len(streaming_mock_server.requests), + expected_count=4, + timeout=10, + ) + wait_for_rendered_text( + child, captured, needle="Rejected file was not written.", timeout=10 + ) + + send_ctrl_c_until_quit_confirmation(child, captured, timeout=5) + child.expect(pexpect.EOF, timeout=10) + + assert approved_path.read_text(encoding="utf-8") == "approved content\n" + assert not rejected_path.exists() + assert_tool_result_contains( + streaming_mock_server.requests[1], + call_id=WRITE_APPROVED_CALL_ID, + expected="approved content", + ) + assert_tool_result_contains( + streaming_mock_server.requests[3], + call_id=WRITE_REJECTED_CALL_ID, + expected="permanently disabled", + ) + + +@pytest.mark.timeout(40) +@pytest.mark.parametrize( + "streaming_mock_server", + [pytest.param(_session_permission_factory, id="session-permission-memory")], + indirect=True, +) +def test_allow_for_session_reuses_bash_permission_without_prompting_again( + 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("Run the first shell command") + child.send("\r") + + wait_for_request_count( + lambda: len(streaming_mock_server.requests), expected_count=1, timeout=10 + ) + answer_approval(child, captured, tool_name="bash", key="2") + wait_for_request_count_while_draining_child_output( + child, + captured, + lambda: len(streaming_mock_server.requests), + expected_count=2, + timeout=10, + ) + wait_for_rendered_text( + child, captured, needle="First command completed.", timeout=10 + ) + + child.send("Run the same shell command again") + child.send("\r") + wait_for_request_count_while_draining_child_output( + child, + captured, + lambda: len(streaming_mock_server.requests), + expected_count=4, + timeout=10, + ) + wait_for_rendered_text( + child, + captured, + needle="Second command reused session permission.", + timeout=10, + ) + + send_ctrl_c_until_quit_confirmation(child, captured, timeout=5) + child.expect(pexpect.EOF, timeout=10) + + assert_tool_result_contains( + streaming_mock_server.requests[1], + call_id=SESSION_PERMISSION_FIRST_CALL_ID, + expected=SESSION_PERMISSION_OUTPUT, + ) + assert_tool_result_contains( + streaming_mock_server.requests[3], + call_id=SESSION_PERMISSION_SECOND_CALL_ID, + expected=SESSION_PERMISSION_OUTPUT, + ) diff --git a/tests/e2e/agent_loop_characterization/test_user_interaction.py b/tests/e2e/agent_loop_characterization/test_user_interaction.py new file mode 100644 index 0000000..1d517b4 --- /dev/null +++ b/tests/e2e/agent_loop_characterization/test_user_interaction.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import io +from pathlib import Path +import time + +import pexpect +import pytest + +from tests.e2e.agent_loop_characterization.support import ( + APPROVAL_INPUT_GRACE_PERIOD_S, + assert_tool_result_contains, + assistant_text_chunks, + single_tool_call_chunks, + wait_for_request_count_while_draining_child_output, +) +from tests.e2e.common import ( + SpawnedVibeProcessFixture, + send_ctrl_c_until_quit_confirmation, + wait_for_main_screen, + wait_for_rendered_text, + wait_for_request_count, +) +from tests.e2e.mock_server import ChatCompletionsRequestPayload, StreamingMockServer + +QUESTION_CALL_ID = "call_question_mode" +QUESTION_TEXT = "Which mode should Vibe use?" + + +def _answer_first_question_option(child: pexpect.spawn, captured: io.StringIO) -> None: + wait_for_rendered_text(child, captured, needle=QUESTION_TEXT, timeout=10) + wait_for_rendered_text(child, captured, needle="Fast", timeout=10) + time.sleep(APPROVAL_INPUT_GRACE_PERIOD_S) + child.send("\r") + + +def _ask_user_question_factory( + request_index: int, _payload: ChatCompletionsRequestPayload +) -> list[dict[str, object]]: + if request_index == 0: + return single_tool_call_chunks( + call_id=QUESTION_CALL_ID, + tool_name="ask_user_question", + arguments={ + "questions": [ + { + "question": QUESTION_TEXT, + "header": "Mode", + "options": [ + {"label": "Fast", "description": "Move quickly"}, + {"label": "Careful", "description": "Add checks"}, + ], + "hide_other": True, + } + ] + }, + created=70, + ) + + return assistant_text_chunks("The selected mode was Fast.", created=80) + + +@pytest.mark.timeout(25) +@pytest.mark.parametrize( + "streaming_mock_server", + [pytest.param(_ask_user_question_factory, id="ask-user-question")], + indirect=True, +) +def test_ask_user_question_waits_for_answer_and_reports_it_to_the_model( + 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("Ask me for a mode") + child.send("\r") + + wait_for_request_count( + lambda: len(streaming_mock_server.requests), expected_count=1, timeout=10 + ) + _answer_first_question_option(child, captured) + wait_for_request_count_while_draining_child_output( + child, + captured, + lambda: len(streaming_mock_server.requests), + expected_count=2, + timeout=10, + ) + wait_for_rendered_text( + child, captured, needle="The selected mode was Fast.", timeout=10 + ) + + send_ctrl_c_until_quit_confirmation(child, captured, timeout=5) + child.expect(pexpect.EOF, timeout=10) + + assert_tool_result_contains( + streaming_mock_server.requests[1], call_id=QUESTION_CALL_ID, expected="Fast" + ) diff --git a/tests/e2e/mock_server.py b/tests/e2e/mock_server.py index c69a4c8..965820a 100644 --- a/tests/e2e/mock_server.py +++ b/tests/e2e/mock_server.py @@ -8,6 +8,8 @@ import threading import time from typing import TypedDict, cast +from tests.constants import CHAT_COMPLETIONS_PATH + class StreamOptionsPayload(TypedDict, total=False): include_usage: bool @@ -85,6 +87,104 @@ class StreamingMockServer: ), ] + @staticmethod + def _merge_tool_call_delta( + tool_calls_by_index: dict[int, dict[str, object]], + delta_tool_call: object, + *, + fallback_index: int | None, + ) -> int | None: + if not isinstance(delta_tool_call, dict): + return fallback_index + + index = delta_tool_call.get("index") + if not isinstance(index, int): + index = fallback_index + if index is None: + index = len(tool_calls_by_index) + + tool_call = tool_calls_by_index.setdefault(index, {"index": index}) + for key in ("id", "type"): + if value := delta_tool_call.get(key): + tool_call[key] = value + + function_delta = delta_tool_call.get("function") + if not isinstance(function_delta, dict): + return index + + function = tool_call.setdefault("function", {}) + if not isinstance(function, dict): + function = {} + tool_call["function"] = function + + if name := function_delta.get("name"): + function["name"] = name + if arguments := function_delta.get("arguments"): + function["arguments"] = f"{function.get('arguments', '')}{arguments}" + return index + + @staticmethod + def _completion_response_from_chunks( + chunks: list[StreamChunk], + ) -> dict[str, object]: + content_parts: list[str] = [] + tool_calls_by_index: dict[int, dict[str, object]] = {} + active_tool_call_index: int | None = None + finish_reason: object = "stop" + usage: object = {"prompt_tokens": 3, "completion_tokens": 4} + created = 123 + + for chunk in chunks: + chunk_created = chunk.get("created") + if isinstance(chunk_created, int): + created = chunk_created + if chunk_usage := chunk.get("usage"): + usage = chunk_usage + choices = chunk.get("choices") + if not isinstance(choices, list) or not choices: + continue + choice = choices[0] + if not isinstance(choice, dict): + continue + if choice.get("finish_reason") is not None: + finish_reason = choice.get("finish_reason") + delta = choice.get("delta") + if not isinstance(delta, dict): + continue + if content := delta.get("content"): + content_parts.append(str(content)) + if delta_tool_calls := delta.get("tool_calls"): + if isinstance(delta_tool_calls, list): + for delta_tool_call in delta_tool_calls: + active_tool_call_index = ( + StreamingMockServer._merge_tool_call_delta( + tool_calls_by_index, + delta_tool_call, + fallback_index=active_tool_call_index, + ) + ) + + message: dict[str, object] = { + "role": "assistant", + "content": "".join(content_parts), + } + tool_calls = [ + tool_calls_by_index[index] for index in sorted(tool_calls_by_index) + ] + if tool_calls: + message["tool_calls"] = tool_calls + + return { + "id": "mock-id", + "object": "chat.completion", + "created": created, + "model": "mock-model", + "choices": [ + {"index": 0, "message": message, "finish_reason": finish_reason} + ], + "usage": usage, + } + def __init__( self, *, @@ -110,7 +210,7 @@ class StreamingMockServer: return def do_POST(self) -> None: - if self.path != "/v1/chat/completions": + if self.path != CHAT_COMPLETIONS_PATH: self.send_response(404) self.end_headers() return @@ -125,17 +225,28 @@ class StreamingMockServer: parent.requests.append(payload) request_index = len(parent.requests) - 1 - self.send_response(200) - self.send_header("Content-Type", "text/event-stream") - self.send_header("Cache-Control", "no-cache") - self.end_headers() - chunks = ( parent._chunk_factory(request_index, payload) if parent._chunk_factory is not None else parent._stream_chunks() ) + if not payload.get("stream"): + response = parent._completion_response_from_chunks(chunks) + response_body = json.dumps(response, ensure_ascii=False).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(response_body))) + self.end_headers() + self.wfile.write(response_body) + self.wfile.flush() + return + + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + for chunk in chunks: data = json.dumps(chunk, ensure_ascii=False) self.wfile.write(f"data: {data}\n\n".encode()) diff --git a/tests/e2e/test_mock_server.py b/tests/e2e/test_mock_server.py new file mode 100644 index 0000000..d6243ac --- /dev/null +++ b/tests/e2e/test_mock_server.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import httpx + +from tests.e2e.mock_server import ChatCompletionsRequestPayload, StreamingMockServer + + +def _split_tool_call_factory( + _request_index: int, _payload: ChatCompletionsRequestPayload +) -> list[dict[str, object]]: + return [ + StreamingMockServer.build_chunk( + created=10, + delta={ + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "id": "call_split", + "type": "function", + "function": {"name": "bash", "arguments": '{"command":"echo'}, + } + ], + }, + finish_reason=None, + ), + StreamingMockServer.build_chunk( + created=11, + delta={"tool_calls": [{"function": {"arguments": ' ok"}'}}]}, + finish_reason=None, + ), + StreamingMockServer.build_chunk( + created=12, + delta={}, + finish_reason="tool_calls", + usage={"prompt_tokens": 7, "completion_tokens": 8}, + ), + ] + + +def test_non_streaming_completion_merges_split_tool_call_deltas() -> None: + server = StreamingMockServer(chunk_factory=_split_tool_call_factory) + server.start() + try: + response = httpx.post( + f"{server.api_base}/chat/completions", + json={"model": "mock-model", "messages": [], "stream": False}, + timeout=5, + ) + finally: + server.stop() + + response.raise_for_status() + response_json = response.json() + choices = response_json["choices"] + assert isinstance(choices, list) + choice = choices[0] + assert isinstance(choice, dict) + message = choice["message"] + assert isinstance(message, dict) + tool_calls = message["tool_calls"] + assert isinstance(tool_calls, list) + assert tool_calls == [ + { + "index": 0, + "id": "call_split", + "type": "function", + "function": {"name": "bash", "arguments": '{"command":"echo ok"}'}, + } + ] + assert choice["finish_reason"] == "tool_calls" + assert response_json["usage"] == {"prompt_tokens": 7, "completion_tokens": 8} + assert server.requests == [{"model": "mock-model", "messages": [], "stream": False}] diff --git a/tests/onboarding/test_ui_onboarding.py b/tests/onboarding/test_ui_onboarding.py index b49e7b9..da95cc2 100644 --- a/tests/onboarding/test_ui_onboarding.py +++ b/tests/onboarding/test_ui_onboarding.py @@ -7,6 +7,8 @@ from pathlib import Path import tomllib from typing import cast +import keyring +from keyring.errors import KeyringError import pytest from textual.events import Resize from textual.geometry import Size @@ -59,6 +61,17 @@ BROWSER_AUTH_API_URL = "https://console.mistral.ai/api" TEST_NOW = datetime(2026, 3, 16, tzinfo=UTC) +@pytest.fixture(autouse=True) +def disable_keyring(monkeypatch: pytest.MonkeyPatch) -> None: + """Force the .env fallback so persist tests never touch the real OS keyring.""" + + def _unavailable(service: str, username: str, password: str) -> None: + raise KeyringError("keyring disabled in tests") + + monkeypatch.setattr(keyring, "set_password", _unavailable) + monkeypatch.setattr(keyring, "delete_password", lambda service, username: None) + + def _expected_browser_sign_in_url(process_id: str = "process-1") -> str: return build_sign_in_process(TEST_NOW, process_id=process_id).sign_in_url diff --git a/tests/session/test_resume_sessions.py b/tests/session/test_resume_sessions.py index f6c364e..486bb64 100644 --- a/tests/session/test_resume_sessions.py +++ b/tests/session/test_resume_sessions.py @@ -1,82 +1,15 @@ from __future__ import annotations -import asyncio -from collections.abc import Sequence -from dataclasses import dataclass -from datetime import datetime from unittest.mock import MagicMock -import pytest - -from vibe.core.nuage.workflow import ( - WorkflowExecutionListResponse, - WorkflowExecutionStatus, - WorkflowExecutionWithoutResultResponse, -) from vibe.core.session.resume_sessions import ( - RemoteResumeResult, - RemoteResumeSessions, ResumeSessionInfo, - can_delete_resume_session_source, - list_remote_resume_sessions, session_latest_messages, short_session_id, ) from vibe.core.session.session_id import shorten_session_id -@dataclass(frozen=True) -class RemoteResumeRequest: - workflow_identifier: str | None - page_size: int - status: Sequence[WorkflowExecutionStatus] | None - - -class FakeRemoteResumeClient: - def __init__( - self, - response: WorkflowExecutionListResponse | None = None, - *, - delay: float = 0.0, - ) -> None: - self.response = response or WorkflowExecutionListResponse(executions=[]) - self.delay = delay - self.requests: list[RemoteResumeRequest] = [] - self.closed = False - - async def get_workflow_runs( - self, - workflow_identifier: str | None = None, - page_size: int = 50, - next_page_token: str | None = None, - status: Sequence[WorkflowExecutionStatus] | None = None, - user_id: str = "current", - ) -> WorkflowExecutionListResponse: - self.requests.append( - RemoteResumeRequest( - workflow_identifier=workflow_identifier, - page_size=page_size, - status=status, - ) - ) - if self.delay: - await asyncio.sleep(self.delay) - return self.response - - async def aclose(self) -> None: - self.closed = True - - -def enabled_vibe_code_config() -> MagicMock: - config = MagicMock() - config.vibe_code_enabled = True - config.vibe_code_api_key = "test-key" - config.vibe_code_base_url = "https://test.example.com" - config.api_timeout = 30 - config.vibe_code_workflow_id = "workflow-1" - return config - - class TestShortenSessionId: def test_shortens_to_first_8_chars(self) -> None: sid = "abcdef1234567890" @@ -93,215 +26,18 @@ class TestShortenSessionId: class TestShortSessionId: - def test_local_delegates_to_shorten(self) -> None: + def test_delegates_to_shorten(self) -> None: sid = "abcdef1234567890" assert short_session_id(sid) == shorten_session_id(sid) - def test_local_is_default(self) -> None: - sid = "abcdef1234567890" - assert short_session_id(sid) == short_session_id(sid, source="local") - - def test_remote_delegates_to_shorten_from_end(self) -> None: - sid = "abcdef1234567890" - assert short_session_id(sid, source="remote") == shorten_session_id( - sid, from_end=True - ) - def test_empty_string(self) -> None: assert short_session_id("") == "" -class TestCanDeleteResumeSession: - def test_local_source_can_delete(self) -> None: - assert can_delete_resume_session_source("local") is True - - def test_remote_source_cannot_delete(self) -> None: - assert can_delete_resume_session_source("remote") is False - - def test_session_info_can_delete_matches_source(self) -> None: - session = ResumeSessionInfo( - session_id="session-a", - source="local", - cwd="/test", - title=None, - end_time=None, - ) - - assert session.can_delete is True - - -class TestListRemoteResumeSessions: - @pytest.mark.asyncio - async def test_passes_active_statuses_to_api(self) -> None: - running = WorkflowExecutionWithoutResultResponse( - workflow_name="vibe", - execution_id="exec-running", - status=WorkflowExecutionStatus.RUNNING, - start_time=datetime(2026, 1, 1), - end_time=None, - ) - continued = WorkflowExecutionWithoutResultResponse( - workflow_name="vibe", - execution_id="exec-continued", - status=WorkflowExecutionStatus.CONTINUED_AS_NEW, - start_time=datetime(2026, 1, 1), - end_time=None, - ) - - mock_response = WorkflowExecutionListResponse(executions=[running, continued]) - client = FakeRemoteResumeClient(mock_response) - - result = await list_remote_resume_sessions(client, "workflow-1") - - assert len(result) == 2 - session_ids = {s.session_id for s in result} - assert "exec-running" in session_ids - assert "exec-continued" in session_ids - assert all(s.source == "remote" for s in result) - assert client.requests == [ - RemoteResumeRequest( - workflow_identifier="workflow-1", - page_size=50, - status=[ - WorkflowExecutionStatus.RUNNING, - WorkflowExecutionStatus.CONTINUED_AS_NEW, - ], - ) - ] - - @pytest.mark.asyncio - async def test_deduplicates_execution_ids_keeps_latest(self) -> None: - older = WorkflowExecutionWithoutResultResponse( - workflow_name="vibe", - execution_id="exec-1", - status=WorkflowExecutionStatus.RUNNING, - start_time=datetime(2026, 1, 1), - end_time=None, - ) - newer = WorkflowExecutionWithoutResultResponse( - workflow_name="vibe", - execution_id="exec-1", - status=WorkflowExecutionStatus.RUNNING, - start_time=datetime(2026, 1, 5), - end_time=None, - ) - other = WorkflowExecutionWithoutResultResponse( - workflow_name="vibe", - execution_id="exec-2", - status=WorkflowExecutionStatus.RUNNING, - start_time=datetime(2026, 1, 3), - end_time=None, - ) - - mock_response = WorkflowExecutionListResponse(executions=[older, newer, other]) - client = FakeRemoteResumeClient(mock_response) - - result = await list_remote_resume_sessions(client, "workflow-1") - - assert len(result) == 2 - by_id = {s.session_id: s for s in result} - assert by_id["exec-1"].end_time == datetime(2026, 1, 5).isoformat() - assert "exec-2" in by_id - - @pytest.mark.asyncio - async def test_dedup_keeps_latest_start_time_when_previous_has_end_time( - self, - ) -> None: - previous = WorkflowExecutionWithoutResultResponse( - workflow_name="vibe", - execution_id="exec-1", - status=WorkflowExecutionStatus.FAILED, - start_time=datetime(2026, 1, 1), - end_time=datetime(2026, 1, 10), - ) - newer = WorkflowExecutionWithoutResultResponse( - workflow_name="vibe", - execution_id="exec-1", - status=WorkflowExecutionStatus.RUNNING, - start_time=datetime(2026, 1, 5), - end_time=None, - ) - - mock_response = WorkflowExecutionListResponse(executions=[previous, newer]) - client = FakeRemoteResumeClient(mock_response) - - result = await list_remote_resume_sessions(client, "workflow-1") - - assert len(result) == 1 - assert result[0].session_id == "exec-1" - assert result[0].status == WorkflowExecutionStatus.RUNNING - - class TestSessionLatestMessages: - def test_remote_session_formats_title_and_status(self) -> None: + def test_uses_session_title_when_present(self) -> None: session = ResumeSessionInfo( - session_id="exec-1", - source="remote", - cwd="", - title="My run", - end_time=None, - status="RUNNING", + session_id="session-a", cwd="/test", title="My run", end_time=None ) messages = session_latest_messages([session], MagicMock()) - assert messages[session.option_id] == "My run (running)" - - -class TestRemoteResumeSessions: - @pytest.mark.asyncio - async def test_fetch_skips_when_vibe_code_disabled(self) -> None: - config = MagicMock() - config.vibe_code_enabled = False - config.vibe_code_api_key = "key" - created_clients: list[FakeRemoteResumeClient] = [] - - def client_factory(_config: object) -> FakeRemoteResumeClient: - client = FakeRemoteResumeClient() - created_clients.append(client) - return client - - remote = RemoteResumeSessions(lambda: config, client_factory) - - result = await remote.fetch(10.0) - - assert result == RemoteResumeResult([], None) - assert created_clients == [] - - @pytest.mark.asyncio - async def test_start_cancels_previous_fetch(self) -> None: - config = enabled_vibe_code_config() - client = FakeRemoteResumeClient(delay=10.0) - remote = RemoteResumeSessions(lambda: config, lambda _config: client) - - first = remote.start(100.0) - await asyncio.sleep(0) - second = remote.start(100.0) - await asyncio.sleep(0) - await remote.aclose() - - assert first.cancelled() - assert first is not second - - @pytest.mark.asyncio - async def test_fetch_returns_error_tuple_on_timeout(self) -> None: - config = enabled_vibe_code_config() - client = FakeRemoteResumeClient(delay=10.0) - remote = RemoteResumeSessions(lambda: config, lambda _config: client) - - sessions, error = await remote.fetch(0.01) - await remote.aclose() - - assert sessions == [] - assert error is not None and "Timed out" in error - - @pytest.mark.asyncio - async def test_aclose_cancels_inflight_fetch_before_closing_client(self) -> None: - config = enabled_vibe_code_config() - client = FakeRemoteResumeClient(delay=10.0) - remote = RemoteResumeSessions(lambda: config, lambda _config: client) - - task = remote.start(100.0) - await asyncio.sleep(0) - await remote.aclose() - - assert task.cancelled() - assert client.closed is True + assert messages[session.option_id] == "My run" diff --git a/tests/setup/auth/test_api_key_persistence.py b/tests/setup/auth/test_api_key_persistence.py new file mode 100644 index 0000000..39d25cf --- /dev/null +++ b/tests/setup/auth/test_api_key_persistence.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import os + +from dotenv import dotenv_values, set_key +import keyring +from keyring.errors import KeyringError, NoKeyringError, PasswordDeleteError +import pytest + +from vibe.core.config import ProviderConfig +from vibe.core.paths import GLOBAL_ENV_FILE +from vibe.core.types import Backend +from vibe.setup.auth.api_key_persistence import persist_api_key, remove_api_key + + +def _provider(*, api_key_env_var: str = "CUSTOM_API_KEY") -> ProviderConfig: + # Backend.GENERIC keeps onboarding telemetry out of these unit tests. + return ProviderConfig( + name="custom", + api_base="https://custom.example/v1", + api_key_env_var=api_key_env_var, + backend=Backend.GENERIC, + ) + + +def test_persist_stores_in_keyring_and_clears_stale_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + stored: dict[str, str] = {} + monkeypatch.delenv("CUSTOM_API_KEY", raising=False) + monkeypatch.setattr( + keyring, + "set_password", + lambda service, username, password: stored.__setitem__(username, password), + ) + # A stale plaintext copy that should be dropped after the keyring write. + GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True) + set_key(GLOBAL_ENV_FILE.path, "CUSTOM_API_KEY", "old-key") + + result = persist_api_key(_provider(), "new-key") + + assert result == "completed" + assert stored == {"CUSTOM_API_KEY": "new-key"} + assert os.environ["CUSTOM_API_KEY"] == "new-key" + assert "CUSTOM_API_KEY" not in dotenv_values(GLOBAL_ENV_FILE.path) + + +def test_persist_falls_back_to_env_when_keyring_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("CUSTOM_API_KEY", raising=False) + + def _unavailable(service: str, username: str, password: str) -> None: + raise KeyringError("no keyring") + + monkeypatch.setattr(keyring, "set_password", _unavailable) + + result = persist_api_key(_provider(), "new-key") + + assert result == "completed" + assert os.environ["CUSTOM_API_KEY"] == "new-key" + assert dotenv_values(GLOBAL_ENV_FILE.path)["CUSTOM_API_KEY"] == "new-key" + + +def test_persist_returns_env_var_error_for_empty_env_var( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _fail(service: str, username: str, password: str) -> None: + raise AssertionError("keyring should not be used for an empty env var") + + monkeypatch.setattr(keyring, "set_password", _fail) + + result = persist_api_key(_provider(api_key_env_var=""), "new-key") + + assert result == "env_var_error:<empty>" + + +def test_remove_deletes_keyring_env_and_process_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + deleted: list[str] = [] + monkeypatch.setenv("CUSTOM_API_KEY", "live-key") + monkeypatch.setattr( + keyring, "delete_password", lambda service, username: deleted.append(username) + ) + GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True) + set_key(GLOBAL_ENV_FILE.path, "CUSTOM_API_KEY", "file-key") + + remove_api_key(_provider()) + + assert deleted == ["CUSTOM_API_KEY"] + assert "CUSTOM_API_KEY" not in dotenv_values(GLOBAL_ENV_FILE.path) + assert "CUSTOM_API_KEY" not in os.environ + + +def test_remove_ignores_keyring_unavailable_and_still_clears_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("CUSTOM_API_KEY", "live-key") + + def _unavailable(service: str, username: str) -> None: + raise NoKeyringError("no keyring") + + monkeypatch.setattr(keyring, "delete_password", _unavailable) + GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True) + set_key(GLOBAL_ENV_FILE.path, "CUSTOM_API_KEY", "file-key") + + remove_api_key(_provider()) + + assert "CUSTOM_API_KEY" not in dotenv_values(GLOBAL_ENV_FILE.path) + assert "CUSTOM_API_KEY" not in os.environ + + +def test_remove_ignores_missing_keyring_entry_and_still_clears_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("CUSTOM_API_KEY", "live-key") + + def _missing(service: str, username: str) -> None: + raise PasswordDeleteError("not found") + + monkeypatch.setattr(keyring, "delete_password", _missing) + GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True) + set_key(GLOBAL_ENV_FILE.path, "CUSTOM_API_KEY", "file-key") + + remove_api_key(_provider()) + + assert "CUSTOM_API_KEY" not in dotenv_values(GLOBAL_ENV_FILE.path) + assert "CUSTOM_API_KEY" not in os.environ + + +def test_remove_surfaces_keyring_operation_error_but_still_clears_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("CUSTOM_API_KEY", "live-key") + + def _failed(service: str, username: str) -> None: + raise KeyringError("delete failed") + + monkeypatch.setattr(keyring, "delete_password", _failed) + GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True) + set_key(GLOBAL_ENV_FILE.path, "CUSTOM_API_KEY", "file-key") + + with pytest.raises(KeyringError, match="delete failed"): + remove_api_key(_provider()) + + assert "CUSTOM_API_KEY" not in dotenv_values(GLOBAL_ENV_FILE.path) + assert "CUSTOM_API_KEY" not in os.environ diff --git a/tests/setup/auth/test_auth_state.py b/tests/setup/auth/test_auth_state.py index 2b6b1b6..85382ef 100644 --- a/tests/setup/auth/test_auth_state.py +++ b/tests/setup/auth/test_auth_state.py @@ -2,11 +2,19 @@ from __future__ import annotations from pathlib import Path +import keyring +import pytest + from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, ProviderConfig from vibe.core.types import Backend from vibe.setup.auth import AuthState, AuthStateKind, assess_auth_state +@pytest.fixture(autouse=True) +def disable_keyring(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(keyring, "get_password", lambda service, username: None) + + def _mistral_provider( *, api_key_env_var: str = DEFAULT_MISTRAL_API_ENV_KEY ) -> ProviderConfig: @@ -100,7 +108,7 @@ def test_assess_process_env_when_default_key_is_only_in_process_env( ) -def test_assess_vibe_home_env_file_overrides_process_env_when_both_sources_exist( +def test_assess_process_env_when_process_env_and_dotenv_both_exist( tmp_path: Path, ) -> None: env_path = tmp_path / ".env" @@ -114,9 +122,9 @@ def test_assess_vibe_home_env_file_overrides_process_env_when_both_sources_exist ) assert state == AuthState( - kind=AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV, + kind=AuthStateKind.PROCESS_ENV, can_use_active_provider=True, - sign_out_available=True, + sign_out_available=False, env_key=DEFAULT_MISTRAL_API_ENV_KEY, ) @@ -214,3 +222,62 @@ def test_assess_empty_dotenv_value_as_signed_out(tmp_path: Path) -> None: sign_out_available=False, env_key=DEFAULT_MISTRAL_API_ENV_KEY, ) + + +def test_assess_os_keyring_when_default_key_is_in_keyring( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + keyring, "get_password", lambda service, username: "keyring-key" + ) + + state = assess_auth_state( + _mistral_provider(), env_path=tmp_path / ".env", environ={} + ) + + assert state == AuthState( + kind=AuthStateKind.OS_KEYRING, + can_use_active_provider=True, + sign_out_available=True, + env_key=DEFAULT_MISTRAL_API_ENV_KEY, + ) + + +def test_assess_vibe_home_env_file_when_dotenv_and_keyring_both_have_value( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # resolve_api_key reads the .env-injected os.environ value before the keyring, + # so an overlap must be reported as the .env file, not OS keyring. + monkeypatch.setattr( + keyring, "get_password", lambda service, username: "keyring-key" + ) + env_path = tmp_path / ".env" + _write_env_file(env_path, f"{DEFAULT_MISTRAL_API_ENV_KEY}=file-key\n") + + state = assess_auth_state(_mistral_provider(), env_path=env_path, environ={}) + + assert state == AuthState( + kind=AuthStateKind.VIBE_HOME_ENV_FILE, + can_use_active_provider=True, + sign_out_available=True, + env_key=DEFAULT_MISTRAL_API_ENV_KEY, + ) + + +def test_assess_unsupported_provider_when_custom_key_is_in_keyring( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + keyring, "get_password", lambda service, username: "keyring-key" + ) + + state = assess_auth_state( + _generic_provider(), env_path=tmp_path / ".env", environ={} + ) + + assert state == AuthState( + kind=AuthStateKind.UNSUPPORTED_PROVIDER, + can_use_active_provider=True, + sign_out_available=False, + env_key="CUSTOM_API_KEY", + ) diff --git a/tests/setup/test_update_prompt_dialog.py b/tests/setup/test_update_prompt_dialog.py index 1fbad10..b1fa9bc 100644 --- a/tests/setup/test_update_prompt_dialog.py +++ b/tests/setup/test_update_prompt_dialog.py @@ -8,6 +8,7 @@ import pytest from vibe.setup.update_prompt.update_prompt_dialog import ( UpdateChoice, UpdatePromptApp, + UpdatePromptMode, UpdatePromptResult, ) @@ -75,6 +76,33 @@ async def test_dialog_default_selection_is_update() -> None: assert app._dialog.selected is UpdateChoice.UPDATE +@pytest.mark.asyncio +async def test_startup_prompt_uses_continue_label() -> None: + app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0") + + async with app.run_test() as pilot: + await pilot.pause() + assert app._dialog is not None + assert ( + app._dialog._choice_labels[UpdateChoice.CONTINUE] + == "Continue with current version" + ) + + +@pytest.mark.asyncio +async def test_check_upgrade_prompt_uses_cancel_label() -> None: + app = UpdatePromptApp( + current_version="1.0.0", + latest_version="2.0.0", + prompt_mode=UpdatePromptMode.CHECK_UPGRADE, + ) + + async with app.run_test() as pilot: + await pilot.pause() + assert app._dialog is not None + assert app._dialog._choice_labels[UpdateChoice.CONTINUE] == "Cancel upgrade" + + @pytest.mark.asyncio async def test_dialog_returns_quit_on_ctrl_q() -> None: app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0") diff --git a/tests/setup/test_update_prompt_theme.py b/tests/setup/test_update_prompt_theme.py new file mode 100644 index 0000000..a8bce99 --- /dev/null +++ b/tests/setup/test_update_prompt_theme.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +import tomli_w + +from vibe.core.config import DEFAULT_THEME +from vibe.core.trusted_folders import trusted_folders_manager +from vibe.setup.update_prompt import load_update_prompt_theme + + +def _write_config(path: Path, **data: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(tomli_w.dumps(data), encoding="utf-8") + + +def test_env_theme_overrides_config_file(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + _write_config(config_file, theme="textual-light") + + theme = load_update_prompt_theme( + environ={"VIBE_THEME": "dracula"}, config_file=config_file + ) + + assert theme == "dracula" + + +def test_config_theme_is_used_when_env_theme_is_missing(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + _write_config(config_file, theme="textual-light") + + theme = load_update_prompt_theme(environ={}, config_file=config_file) + + assert theme == "textual-light" + + +def test_invalid_theme_falls_back_to_default(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + _write_config(config_file, theme="unknown-theme") + + theme = load_update_prompt_theme(environ={}, config_file=config_file) + + assert theme == DEFAULT_THEME + + +def test_invalid_env_theme_does_not_fall_through_to_config(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + _write_config(config_file, theme="dracula") + + theme = load_update_prompt_theme( + environ={"VIBE_THEME": "unknown-theme"}, config_file=config_file + ) + + assert theme == DEFAULT_THEME + + +def test_trust_aware_config_source_ignores_untrusted_project_theme( + config_dir: Path, tmp_working_directory: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VIBE_HOME", raising=False) + _write_config(config_dir / "config.toml", theme="textual-light") + _write_config(tmp_working_directory / ".vibe" / "config.toml", theme="dracula") + + theme = load_update_prompt_theme(environ={}) + + assert theme == "textual-light" + + +def test_trust_aware_config_source_can_use_trusted_project_theme( + tmp_working_directory: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("VIBE_HOME", raising=False) + _write_config(tmp_working_directory / ".vibe" / "config.toml", theme="dracula") + trusted_folders_manager.add_trusted(tmp_working_directory) + + theme = load_update_prompt_theme(environ={}) + + assert theme == "dracula" 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 41a6b22..5fc570d 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg @@ -164,7 +164,7 @@ </g> <g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)"> - <rect fill="#4b4e55" x="24.4" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="36.6" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="48.8" y="635.9" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="183" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="195.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/> + <rect fill="#4b4e55" x="24.4" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="36.6" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="48.8" y="635.9" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="183" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="195.2" y="635.9" width="1171.2" height="24.65" shape-rendering="crispEdges"/> <g class="terminal-matrix"> <text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"> </text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"> @@ -191,8 +191,8 @@ </text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"> </text><text class="terminal-r1" x="24.4" y="581.2" textLength="36.6" clip-path="url(#terminal-line-23)">():</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"> </text><text class="terminal-r4" x="24.4" y="605.6" textLength="329.4" clip-path="url(#terminal-line-24)">ery long line (Lorem Ipsum)</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"> -</text><text class="terminal-r5" x="24.4" y="630" textLength="1366.4" clip-path="url(#terminal-line-25)">m ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna </text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> -</text><text class="terminal-r7" x="36.6" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">▋</text><text class="terminal-r9" x="183" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">▉</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> +</text><text class="terminal-r5" x="24.4" y="630" textLength="1342" clip-path="url(#terminal-line-25)">m ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magn</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> +</text><text class="terminal-r7" x="36.6" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">▌</text><text class="terminal-r9" x="183" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">▍</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> </text><text class="terminal-r1" x="24.4" y="678.8" textLength="48.8" clip-path="url(#terminal-line-27)">The </text><text class="terminal-r10" x="73.2" y="678.8" textLength="61" clip-path="url(#terminal-line-27)">print</text><text class="terminal-r1" x="134.2" y="678.8" textLength="1085.8" clip-path="url(#terminal-line-27)"> statement includes a very long line of Lorem Ipsum text to demonstrate a lengthy output.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> </text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> </text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"> diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_data_retention/test_snapshot_data_retention.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_data_retention/test_snapshot_data_retention.svg index 0f60d3c..dded449 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_data_retention/test_snapshot_data_retention.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_data_retention/test_snapshot_data_retention.svg @@ -184,9 +184,9 @@ </text><text class="terminal-r1" x="0" y="508" textLength="61" clip-path="url(#terminal-line-20)">Type </text><text class="terminal-r3" x="61" y="508" textLength="61" clip-path="url(#terminal-line-20)">/help</text><text class="terminal-r1" x="122" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> for more information</text><text class="terminal-r1" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"> </text><text class="terminal-r1" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"> </text><text class="terminal-r1" x="24.4" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">⎢</text><text class="terminal-r4" x="48.8" y="556.8" textLength="414.8" clip-path="url(#terminal-line-22)">Your Data Helps Improve Mistral AI</text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"> -</text><text class="terminal-r1" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">⎢</text><text class="terminal-r1" x="48.8" y="581.2" textLength="1159" clip-path="url(#terminal-line-23)">At Mistral AI, we're committed to delivering the best possible experience. When you use Mistral</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"> -</text><text class="terminal-r1" x="24.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">⎢</text><text class="terminal-r1" x="48.8" y="605.6" textLength="1159" clip-path="url(#terminal-line-24)">models on our API, your interactions may be collected to improve our models, ensuring they stay</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"> -</text><text class="terminal-r1" x="24.4" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">⎢</text><text class="terminal-r1" x="48.8" y="630" textLength="439.2" clip-path="url(#terminal-line-25)">cutting-edge, accurate, and helpful.</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> +</text><text class="terminal-r1" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">⎢</text><text class="terminal-r1" x="48.8" y="581.2" textLength="1061.4" clip-path="url(#terminal-line-23)">At Mistral AI, we're committed to delivering the best possible experience. When you use</text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"> +</text><text class="terminal-r1" x="24.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">⎢</text><text class="terminal-r1" x="48.8" y="605.6" textLength="1134.6" clip-path="url(#terminal-line-24)">Mistral models on our API, your interactions may be collected to improve our models, ensuring</text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"> +</text><text class="terminal-r1" x="24.4" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">⎢</text><text class="terminal-r1" x="48.8" y="630" textLength="561.2" clip-path="url(#terminal-line-25)">they stay cutting-edge, accurate, and helpful.</text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> </text><text class="terminal-r1" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">⎢</text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> </text><text class="terminal-r1" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">⎣</text><text class="terminal-r1" x="48.8" y="678.8" textLength="317.2" clip-path="url(#terminal-line-27)">Manage your data settings </text><text class="terminal-r5" x="366" y="678.8" textLength="48.8" clip-path="url(#terminal-line-27)">here</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> </text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_replace_all.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_replace_all.svg new file mode 100644 index 0000000..5b96157 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_edit_diff/test_snapshot_edit_approval_diff_replace_all.svg @@ -0,0 +1,190 @@ +<svg class="rich-terminal" viewBox="0 0 1238 782.0" xmlns="http://www.w3.org/2000/svg"> + <!-- Generated with Rich https://www.textualize.io --> + <style> + + @font-face { + font-family: "Fira Code"; + src: local("FiraCode-Regular"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); + font-style: normal; + font-weight: 400; + } + @font-face { + font-family: "Fira Code"; + src: local("FiraCode-Bold"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); + font-style: bold; + font-weight: 700; + } + + .terminal-matrix { + font-family: Fira Code, monospace; + font-size: 20px; + line-height: 24.4px; + font-variant-east-asian: full-width; + } + + .terminal-title { + font-size: 18px; + font-weight: bold; + font-family: arial; + } + + .terminal-r1 { fill: #a9b1d6 } +.terminal-r2 { fill: #c5c8c6 } +.terminal-r3 { fill: #ff8205;font-weight: bold } +.terminal-r4 { fill: #1a1b26 } +.terminal-r5 { fill: #7aa2f7 } +.terminal-r6 { fill: #6f758f } +.terminal-r7 { fill: #dfaf68;font-weight: bold } +.terminal-r8 { fill: #a3a3a8 } +.terminal-r9 { fill: #b8b4b8 } +.terminal-r10 { fill: #f9a4b4 } +.terminal-r11 { fill: #d2bcf9 } +.terminal-r12 { fill: #ffffff } +.terminal-r13 { fill: #ffffff;font-weight: bold } +.terminal-r14 { fill: #eaca9b } +.terminal-r15 { fill: #b5b7b7 } +.terminal-r16 { fill: #bede9c } +.terminal-r17 { fill: #9ece6a;font-weight: bold } +.terminal-r18 { fill: #f6768e } + </style> + + <defs> + <clipPath id="terminal-clip-terminal"> + <rect x="0" y="0" width="1219.0" height="731.0" /> + </clipPath> + <clipPath id="terminal-line-0"> + <rect x="0" y="1.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-1"> + <rect x="0" y="25.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-2"> + <rect x="0" y="50.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-3"> + <rect x="0" y="74.7" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-4"> + <rect x="0" y="99.1" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-5"> + <rect x="0" y="123.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-6"> + <rect x="0" y="147.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-7"> + <rect x="0" y="172.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-8"> + <rect x="0" y="196.7" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-9"> + <rect x="0" y="221.1" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-10"> + <rect x="0" y="245.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-11"> + <rect x="0" y="269.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-12"> + <rect x="0" y="294.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-13"> + <rect x="0" y="318.7" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-14"> + <rect x="0" y="343.1" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-15"> + <rect x="0" y="367.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-16"> + <rect x="0" y="391.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-17"> + <rect x="0" y="416.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-18"> + <rect x="0" y="440.7" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-19"> + <rect x="0" y="465.1" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-20"> + <rect x="0" y="489.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-21"> + <rect x="0" y="513.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-22"> + <rect x="0" y="538.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-23"> + <rect x="0" y="562.7" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-24"> + <rect x="0" y="587.1" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-25"> + <rect x="0" y="611.5" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-26"> + <rect x="0" y="635.9" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-27"> + <rect x="0" y="660.3" width="1220" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-28"> + <rect x="0" y="684.7" width="1220" height="24.65"/> + </clipPath> + </defs> + + <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1236" height="780" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="618" y="27">EditReplaceAllApprovalApp</text> + <g transform="translate(26,22)"> + <circle cx="0" cy="0" r="7" fill="#ff5f57"/> + <circle cx="22" cy="0" r="7" fill="#febc2e"/> + <circle cx="44" cy="0" r="7" fill="#28c840"/> + </g> + + <g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)"> + <rect fill="#1a1b26" x="0" y="1.5" width="134.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="134.2" y="1.5" width="646.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="780.8" y="1.5" width="427" height="24.65" shape-rendering="crispEdges"/><rect fill="#070817" x="1207.8" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="25.9" width="780.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="780.8" y="25.9" width="427" height="24.65" shape-rendering="crispEdges"/><rect fill="#070817" x="1207.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="50.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="146.4" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="158.6" y="50.3" width="109.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="268.4" y="50.3" width="244" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="512.4" y="50.3" width="256.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="768.6" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="780.8" y="50.3" width="427" height="24.65" shape-rendering="crispEdges"/><rect fill="#4f4270" x="1207.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="74.7" width="414.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="414.8" y="74.7" width="366" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="780.8" y="74.7" width="427" height="24.65" shape-rendering="crispEdges"/><rect fill="#4f4270" x="1207.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="99.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="61" y="99.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="122" y="99.1" width="256.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="378.2" y="99.1" width="402.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="780.8" y="99.1" width="427" height="24.65" shape-rendering="crispEdges"/><rect fill="#4f4270" x="1207.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="123.5" width="1207.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#4f4270" x="1207.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="147.9" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="172.3" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="172.3" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="196.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="24.4" y="221.1" width="341.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="366" y="221.1" width="841.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="24.4" y="245.5" width="244" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="268.4" y="245.5" width="939.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="24.4" y="269.9" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="24.4" y="269.9" width="1183.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#302430" x="24.4" y="294.3" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#302430" x="85.4" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#302430" x="109.8" y="294.3" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#302430" x="170.8" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#302430" x="183" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#302430" x="195.2" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#302430" x="207.4" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#302430" x="219.6" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1195.6" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#272c2c" x="24.4" y="318.7" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#272c2c" x="85.4" y="318.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#272c2c" x="109.8" y="318.7" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#272c2c" x="170.8" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#272c2c" x="183" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#272c2c" x="195.2" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#272c2c" x="207.4" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#272c2c" x="219.6" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1195.6" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="24.4" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="36.6" y="343.1" width="1171.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#302430" x="24.4" y="367.5" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#302430" x="85.4" y="367.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#302430" x="109.8" y="367.5" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#302430" x="170.8" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#302430" x="183" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#302430" x="195.2" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#302430" x="207.4" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#302430" x="219.6" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1195.6" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#272c2c" x="24.4" y="391.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#272c2c" x="85.4" y="391.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#272c2c" x="109.8" y="391.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#272c2c" x="170.8" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#272c2c" x="183" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#272c2c" x="195.2" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#272c2c" x="207.4" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#272c2c" x="219.6" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1195.6" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="24.4" y="416.3" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="183" y="416.3" width="1024.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="24.4" y="440.7" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="24.4" y="440.7" width="1183.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="24.4" y="465.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="207.4" y="465.1" width="1000.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="489.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="489.5" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="489.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="513.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="513.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="24.4" y="513.9" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="512.4" y="513.9" width="695.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="513.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="538.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="538.3" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="538.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="562.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="562.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="24.4" y="562.7" width="207.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="231.8" y="562.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="562.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="587.1" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="24.4" y="611.5" width="109.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="134.2" y="611.5" width="1073.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="635.9" width="1195.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="12.2" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="24.4" y="660.3" width="451.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="475.8" y="660.3" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1207.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="684.7" width="1220" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="0" y="709.1" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="158.6" y="709.1" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="158.6" y="709.1" width="854" height="24.65" shape-rendering="crispEdges"/><rect fill="#1a1b26" x="1012.6" y="709.1" width="207.4" height="24.65" shape-rendering="crispEdges"/> + <g class="terminal-matrix"> + <text class="terminal-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r2" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"> +</text><text class="terminal-r2" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"> +</text><text class="terminal-r3" x="0" y="68.8" textLength="146.4" clip-path="url(#terminal-line-2)">Mistral Vibe</text><text class="terminal-r1" x="158.6" y="68.8" textLength="109.8" clip-path="url(#terminal-line-2)">v0.0.0 · </text><text class="terminal-r5" x="268.4" y="68.8" textLength="244" clip-path="url(#terminal-line-2)">devstral-latest[off]</text><text class="terminal-r1" x="512.4" y="68.8" textLength="256.2" clip-path="url(#terminal-line-2)"> · [Subscription] Pro</text><text class="terminal-r2" x="1220" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"> +</text><text class="terminal-r1" x="0" y="93.2" textLength="414.8" clip-path="url(#terminal-line-3)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r2" x="1220" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"> +</text><text class="terminal-r1" x="0" y="117.6" textLength="61" clip-path="url(#terminal-line-4)">Type </text><text class="terminal-r5" x="61" y="117.6" textLength="61" clip-path="url(#terminal-line-4)">/help</text><text class="terminal-r1" x="122" y="117.6" textLength="256.2" clip-path="url(#terminal-line-4)"> for more information</text><text class="terminal-r2" x="1220" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"> +</text><text class="terminal-r2" x="1220" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"> +</text><text class="terminal-r2" x="1220" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"> +</text><text class="terminal-r2" x="1220" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"> +</text><text class="terminal-r6" x="0" y="215.2" textLength="1220" clip-path="url(#terminal-line-8)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r2" x="1220" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"> +</text><text class="terminal-r6" x="0" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r7" x="24.4" y="239.6" textLength="341.6" clip-path="url(#terminal-line-9)">Permission for the edit tool</text><text class="terminal-r6" x="1207.8" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">│</text><text class="terminal-r2" x="1220" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"> +</text><text class="terminal-r6" x="0" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">│</text><text class="terminal-r8" x="24.4" y="264" textLength="244" clip-path="url(#terminal-line-10)">File: src/counter.py</text><text class="terminal-r6" x="1207.8" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">│</text><text class="terminal-r2" x="1220" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"> +</text><text class="terminal-r6" x="0" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">│</text><text class="terminal-r6" x="1207.8" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">│</text><text class="terminal-r2" x="1220" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"> +</text><text class="terminal-r6" x="0" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">│</text><text class="terminal-r9" x="24.4" y="312.8" textLength="61" clip-path="url(#terminal-line-12)">   2 </text><text class="terminal-r10" x="85.4" y="312.8" textLength="24.4" clip-path="url(#terminal-line-12)">- </text><text class="terminal-r11" x="109.8" y="312.8" textLength="61" clip-path="url(#terminal-line-12)">count</text><text class="terminal-r13" x="183" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">=</text><text class="terminal-r14" x="207.4" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">0</text><text class="terminal-r6" x="1207.8" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">│</text><text class="terminal-r2" x="1220" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"> +</text><text class="terminal-r6" x="0" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">│</text><text class="terminal-r15" x="24.4" y="337.2" textLength="61" clip-path="url(#terminal-line-13)">   2 </text><text class="terminal-r16" x="85.4" y="337.2" textLength="24.4" clip-path="url(#terminal-line-13)">+ </text><text class="terminal-r11" x="109.8" y="337.2" textLength="61" clip-path="url(#terminal-line-13)">count</text><text class="terminal-r13" x="183" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">=</text><text class="terminal-r14" x="207.4" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">1</text><text class="terminal-r6" x="1207.8" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">│</text><text class="terminal-r2" x="1220" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"> +</text><text class="terminal-r6" x="0" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">│</text><text class="terminal-r8" x="24.4" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">⋯</text><text class="terminal-r6" x="1207.8" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">│</text><text class="terminal-r2" x="1220" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"> +</text><text class="terminal-r6" x="0" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">│</text><text class="terminal-r9" x="24.4" y="386" textLength="61" clip-path="url(#terminal-line-15)">   8 </text><text class="terminal-r10" x="85.4" y="386" textLength="24.4" clip-path="url(#terminal-line-15)">- </text><text class="terminal-r11" x="109.8" y="386" textLength="61" clip-path="url(#terminal-line-15)">count</text><text class="terminal-r13" x="183" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">=</text><text class="terminal-r14" x="207.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">0</text><text class="terminal-r6" x="1207.8" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">│</text><text class="terminal-r2" x="1220" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"> +</text><text class="terminal-r6" x="0" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">│</text><text class="terminal-r15" x="24.4" y="410.4" textLength="61" clip-path="url(#terminal-line-16)">   8 </text><text class="terminal-r16" x="85.4" y="410.4" textLength="24.4" clip-path="url(#terminal-line-16)">+ </text><text class="terminal-r11" x="109.8" y="410.4" textLength="61" clip-path="url(#terminal-line-16)">count</text><text class="terminal-r13" x="183" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">=</text><text class="terminal-r14" x="207.4" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">1</text><text class="terminal-r6" x="1207.8" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">│</text><text class="terminal-r2" x="1220" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"> +</text><text class="terminal-r6" x="0" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">│</text><text class="terminal-r8" x="24.4" y="434.8" textLength="158.6" clip-path="url(#terminal-line-17)">(replace_all)</text><text class="terminal-r6" x="1207.8" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">│</text><text class="terminal-r2" x="1220" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"> +</text><text class="terminal-r6" x="0" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">│</text><text class="terminal-r6" x="1207.8" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">│</text><text class="terminal-r2" x="1220" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"> +</text><text class="terminal-r6" x="0" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">│</text><text class="terminal-r17" x="24.4" y="483.6" textLength="183" clip-path="url(#terminal-line-19)">› 1. Allow once</text><text class="terminal-r6" x="1207.8" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">│</text><text class="terminal-r2" x="1220" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"> +</text><text class="terminal-r6" x="0" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">│</text><text class="terminal-r6" x="1207.8" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">│</text><text class="terminal-r2" x="1220" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"> +</text><text class="terminal-r6" x="0" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">│</text><text class="terminal-r1" x="24.4" y="532.4" textLength="488" clip-path="url(#terminal-line-21)">  2. Allow for remainder of this session</text><text class="terminal-r6" x="1207.8" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">│</text><text class="terminal-r2" x="1220" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"> +</text><text class="terminal-r6" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">│</text><text class="terminal-r6" x="1207.8" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">│</text><text class="terminal-r2" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"> +</text><text class="terminal-r6" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">│</text><text class="terminal-r1" x="24.4" y="581.2" textLength="207.4" clip-path="url(#terminal-line-23)">  3. Always allow</text><text class="terminal-r6" x="1207.8" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">│</text><text class="terminal-r2" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"> +</text><text class="terminal-r6" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">│</text><text class="terminal-r6" x="1207.8" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">│</text><text class="terminal-r2" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"> +</text><text class="terminal-r6" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">│</text><text class="terminal-r18" x="24.4" y="630" textLength="109.8" clip-path="url(#terminal-line-25)">  4. Deny</text><text class="terminal-r6" x="1207.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">│</text><text class="terminal-r2" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> +</text><text class="terminal-r6" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">│</text><text class="terminal-r6" x="1207.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">│</text><text class="terminal-r2" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> +</text><text class="terminal-r6" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r8" x="24.4" y="678.8" textLength="451.4" clip-path="url(#terminal-line-27)">↑↓ navigate  Enter select  ESC reject</text><text class="terminal-r6" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">│</text><text class="terminal-r2" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> +</text><text class="terminal-r6" x="0" y="703.2" textLength="1220" clip-path="url(#terminal-line-28)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r2" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> +</text><text class="terminal-r8" x="0" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r8" x="1012.6" y="727.6" textLength="207.4" clip-path="url(#terminal-line-29)">0% of 200k tokens</text> + </g> + </g> +</svg> diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_right_padding/test_snapshot_right_padding_assistant_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_right_padding/test_snapshot_right_padding_assistant_message.svg new file mode 100644 index 0000000..44322f0 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_right_padding/test_snapshot_right_padding_assistant_message.svg @@ -0,0 +1,201 @@ +<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg"> + <!-- Generated with Rich https://www.textualize.io --> + <style> + + @font-face { + font-family: "Fira Code"; + src: local("FiraCode-Regular"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); + font-style: normal; + font-weight: 400; + } + @font-face { + font-family: "Fira Code"; + src: local("FiraCode-Bold"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); + font-style: bold; + font-weight: 700; + } + + .terminal-matrix { + font-family: Fira Code, monospace; + font-size: 20px; + line-height: 24.4px; + font-variant-east-asian: full-width; + } + + .terminal-title { + font-size: 18px; + font-weight: bold; + font-family: arial; + } + + .terminal-r1 { fill: #c5c8c6 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #c5c8c6;font-weight: bold } +.terminal-r5 { fill: #868887 } + </style> + + <defs> + <clipPath id="terminal-clip-terminal"> + <rect x="0" y="0" width="1463.0" height="877.4" /> + </clipPath> + <clipPath id="terminal-line-0"> + <rect x="0" y="1.5" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-1"> + <rect x="0" y="25.9" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-2"> + <rect x="0" y="50.3" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-3"> + <rect x="0" y="74.7" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-4"> + <rect x="0" y="99.1" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-5"> + <rect x="0" y="123.5" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-6"> + <rect x="0" y="147.9" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-7"> + <rect x="0" y="172.3" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-8"> + <rect x="0" y="196.7" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-9"> + <rect x="0" y="221.1" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-10"> + <rect x="0" y="245.5" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-11"> + <rect x="0" y="269.9" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-12"> + <rect x="0" y="294.3" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-13"> + <rect x="0" y="318.7" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-14"> + <rect x="0" y="343.1" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-15"> + <rect x="0" y="367.5" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-16"> + <rect x="0" y="391.9" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-17"> + <rect x="0" y="416.3" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-18"> + <rect x="0" y="440.7" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-19"> + <rect x="0" y="465.1" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-20"> + <rect x="0" y="489.5" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-21"> + <rect x="0" y="513.9" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-22"> + <rect x="0" y="538.3" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-23"> + <rect x="0" y="562.7" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-24"> + <rect x="0" y="587.1" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-25"> + <rect x="0" y="611.5" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-26"> + <rect x="0" y="635.9" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-27"> + <rect x="0" y="660.3" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-28"> + <rect x="0" y="684.7" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-29"> + <rect x="0" y="709.1" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-30"> + <rect x="0" y="733.5" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-31"> + <rect x="0" y="757.9" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-32"> + <rect x="0" y="782.3" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-33"> + <rect x="0" y="806.7" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-34"> + <rect x="0" y="831.1" width="1464" height="24.65"/> + </clipPath> + </defs> + + <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">AssistantLongLineApp</text> + <g transform="translate(26,22)"> + <circle cx="0" cy="0" r="7" fill="#ff5f57"/> + <circle cx="22" cy="0" r="7" fill="#febc2e"/> + <circle cx="44" cy="0" r="7" fill="#28c840"/> + </g> + + <g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)"> + + <g class="terminal-matrix"> + <text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"> +</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"> +</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"> +</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"> +</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"> +</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"> +</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"> +</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"> +</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"> +</text><text class="terminal-r1" x="0" y="239.6" textLength="134.2" clip-path="url(#terminal-line-9)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"> +</text><text class="terminal-r1" x="0" y="264" textLength="134.2" clip-path="url(#terminal-line-10)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"> +</text><text class="terminal-r1" x="0" y="288.4" textLength="134.2" clip-path="url(#terminal-line-11)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"> +</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"> +</text><text class="terminal-r2" x="0" y="337.2" textLength="146.4" clip-path="url(#terminal-line-13)">Mistral Vibe</text><text class="terminal-r1" x="146.4" y="337.2" textLength="122" clip-path="url(#terminal-line-13)"> v0.0.0 · </text><text class="terminal-r3" x="268.4" y="337.2" textLength="244" clip-path="url(#terminal-line-13)">devstral-latest[off]</text><text class="terminal-r1" x="512.4" y="337.2" textLength="256.2" clip-path="url(#terminal-line-13)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"> +</text><text class="terminal-r1" x="0" y="361.6" textLength="414.8" clip-path="url(#terminal-line-14)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"> +</text><text class="terminal-r1" x="0" y="386" textLength="61" clip-path="url(#terminal-line-15)">Type </text><text class="terminal-r3" x="61" y="386" textLength="61" clip-path="url(#terminal-line-15)">/help</text><text class="terminal-r1" x="122" y="386" textLength="256.2" clip-path="url(#terminal-line-15)"> for more information</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"> +</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"> +</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"> +</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"> +</text><text class="terminal-r2" x="0" y="483.6" textLength="24.4" clip-path="url(#terminal-line-19)">> </text><text class="terminal-r4" x="24.4" y="483.6" textLength="280.6" clip-path="url(#terminal-line-19)">Tell me about computing</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"> +</text><text class="terminal-r5" x="0" y="508" textLength="1464" clip-path="url(#terminal-line-20)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"> +</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"> +</text><text class="terminal-r1" x="24.4" y="556.8" textLength="1354.2" clip-path="url(#terminal-line-22)">The history of computing stretches back thousands of years from the abacus through Charles Babbage's Analytical</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"> +</text><text class="terminal-r1" x="24.4" y="581.2" textLength="1342" clip-path="url(#terminal-line-23)">Engine to Alan Turing's theoretical foundations and the first electronic computers like ENIAC and UNIVAC which</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"> +</text><text class="terminal-r1" x="24.4" y="605.6" textLength="1317.6" clip-path="url(#terminal-line-24)">filled entire rooms and consumed enormous amounts of power while performing calculations that today's pocket</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"> +</text><text class="terminal-r1" x="24.4" y="630" textLength="1378.6" clip-path="url(#terminal-line-25)">calculators handle effortlessly and this remarkable progression continued through the invention of the transistor</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> +</text><text class="terminal-r1" x="24.4" y="654.4" textLength="1354.2" clip-path="url(#terminal-line-26)">and integrated circuit leading to the personal computer revolution of the 1980s and the explosive growth of the</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> +</text><text class="terminal-r1" x="24.4" y="678.8" textLength="1354.2" clip-path="url(#terminal-line-27)">internet in the 1990s transforming every aspect of modern life from communication to commerce to entertainment.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> +</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> +</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"> +</text><text class="terminal-r1" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">────────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"> +</text><text class="terminal-r2" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"> +</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"> +</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"> +</text><text class="terminal-r1" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)"> +</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">8% of 200k tokens</text> + </g> + </g> +</svg> diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_right_padding/test_snapshot_right_padding_reasoning.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_right_padding/test_snapshot_right_padding_reasoning.svg new file mode 100644 index 0000000..edab68f --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_right_padding/test_snapshot_right_padding_reasoning.svg @@ -0,0 +1,202 @@ +<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg"> + <!-- Generated with Rich https://www.textualize.io --> + <style> + + @font-face { + font-family: "Fira Code"; + src: local("FiraCode-Regular"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff"); + font-style: normal; + font-weight: 400; + } + @font-face { + font-family: "Fira Code"; + src: local("FiraCode-Bold"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"), + url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff"); + font-style: bold; + font-weight: 700; + } + + .terminal-matrix { + font-family: Fira Code, monospace; + font-size: 20px; + line-height: 24.4px; + font-variant-east-asian: full-width; + } + + .terminal-title { + font-size: 18px; + font-weight: bold; + font-family: arial; + } + + .terminal-r1 { fill: #c5c8c6 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #c5c8c6;font-weight: bold } +.terminal-r5 { fill: #868887 } +.terminal-r6 { fill: #98a84b } + </style> + + <defs> + <clipPath id="terminal-clip-terminal"> + <rect x="0" y="0" width="1463.0" height="877.4" /> + </clipPath> + <clipPath id="terminal-line-0"> + <rect x="0" y="1.5" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-1"> + <rect x="0" y="25.9" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-2"> + <rect x="0" y="50.3" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-3"> + <rect x="0" y="74.7" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-4"> + <rect x="0" y="99.1" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-5"> + <rect x="0" y="123.5" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-6"> + <rect x="0" y="147.9" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-7"> + <rect x="0" y="172.3" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-8"> + <rect x="0" y="196.7" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-9"> + <rect x="0" y="221.1" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-10"> + <rect x="0" y="245.5" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-11"> + <rect x="0" y="269.9" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-12"> + <rect x="0" y="294.3" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-13"> + <rect x="0" y="318.7" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-14"> + <rect x="0" y="343.1" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-15"> + <rect x="0" y="367.5" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-16"> + <rect x="0" y="391.9" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-17"> + <rect x="0" y="416.3" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-18"> + <rect x="0" y="440.7" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-19"> + <rect x="0" y="465.1" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-20"> + <rect x="0" y="489.5" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-21"> + <rect x="0" y="513.9" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-22"> + <rect x="0" y="538.3" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-23"> + <rect x="0" y="562.7" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-24"> + <rect x="0" y="587.1" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-25"> + <rect x="0" y="611.5" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-26"> + <rect x="0" y="635.9" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-27"> + <rect x="0" y="660.3" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-28"> + <rect x="0" y="684.7" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-29"> + <rect x="0" y="709.1" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-30"> + <rect x="0" y="733.5" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-31"> + <rect x="0" y="757.9" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-32"> + <rect x="0" y="782.3" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-33"> + <rect x="0" y="806.7" width="1464" height="24.65"/> + </clipPath> +<clipPath id="terminal-line-34"> + <rect x="0" y="831.1" width="1464" height="24.65"/> + </clipPath> + </defs> + + <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">ReasoningLongLineApp</text> + <g transform="translate(26,22)"> + <circle cx="0" cy="0" r="7" fill="#ff5f57"/> + <circle cx="22" cy="0" r="7" fill="#febc2e"/> + <circle cx="44" cy="0" r="7" fill="#28c840"/> + </g> + + <g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)"> + + <g class="terminal-matrix"> + <text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"> +</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"> +</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"> +</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"> +</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"> +</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"> +</text><text class="terminal-r1" x="0" y="166.4" textLength="134.2" clip-path="url(#terminal-line-6)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"> +</text><text class="terminal-r1" x="0" y="190.8" textLength="134.2" clip-path="url(#terminal-line-7)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"> +</text><text class="terminal-r1" x="0" y="215.2" textLength="134.2" clip-path="url(#terminal-line-8)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"> +</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"> +</text><text class="terminal-r2" x="0" y="264" textLength="146.4" clip-path="url(#terminal-line-10)">Mistral Vibe</text><text class="terminal-r1" x="146.4" y="264" textLength="122" clip-path="url(#terminal-line-10)"> v0.0.0 · </text><text class="terminal-r3" x="268.4" y="264" textLength="244" clip-path="url(#terminal-line-10)">devstral-latest[off]</text><text class="terminal-r1" x="512.4" y="264" textLength="256.2" clip-path="url(#terminal-line-10)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"> +</text><text class="terminal-r1" x="0" y="288.4" textLength="414.8" clip-path="url(#terminal-line-11)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"> +</text><text class="terminal-r1" x="0" y="312.8" textLength="61" clip-path="url(#terminal-line-12)">Type </text><text class="terminal-r3" x="61" y="312.8" textLength="61" clip-path="url(#terminal-line-12)">/help</text><text class="terminal-r1" x="122" y="312.8" textLength="256.2" clip-path="url(#terminal-line-12)"> for more information</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"> +</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"> +</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"> +</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"> +</text><text class="terminal-r2" x="0" y="410.4" textLength="24.4" clip-path="url(#terminal-line-16)">> </text><text class="terminal-r4" x="24.4" y="410.4" textLength="122" clip-path="url(#terminal-line-16)">Think hard</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"> +</text><text class="terminal-r5" x="0" y="434.8" textLength="1464" clip-path="url(#terminal-line-17)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"> +</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"> +</text><text class="terminal-r6" x="0" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">■</text><text class="terminal-r5" x="24.4" y="483.6" textLength="85.4" clip-path="url(#terminal-line-19)">Thought</text><text class="terminal-r5" x="122" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">▼</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"> +</text><text class="terminal-r5" x="24.4" y="508" textLength="1354.2" clip-path="url(#terminal-line-20)">The history of computing stretches back thousands of years from the abacus through Charles Babbage's Analytical</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"> +</text><text class="terminal-r5" x="24.4" y="532.4" textLength="1342" clip-path="url(#terminal-line-21)">Engine to Alan Turing's theoretical foundations and the first electronic computers like ENIAC and UNIVAC which</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"> +</text><text class="terminal-r5" x="24.4" y="556.8" textLength="1317.6" clip-path="url(#terminal-line-22)">filled entire rooms and consumed enormous amounts of power while performing calculations that today's pocket</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"> +</text><text class="terminal-r5" x="24.4" y="581.2" textLength="1378.6" clip-path="url(#terminal-line-23)">calculators handle effortlessly and this remarkable progression continued through the invention of the transistor</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"> +</text><text class="terminal-r5" x="24.4" y="605.6" textLength="1354.2" clip-path="url(#terminal-line-24)">and integrated circuit leading to the personal computer revolution of the 1980s and the explosive growth of the</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"> +</text><text class="terminal-r5" x="24.4" y="630" textLength="1354.2" clip-path="url(#terminal-line-25)">internet in the 1990s transforming every aspect of modern life from communication to commerce to entertainment.</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> +</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> +</text><text class="terminal-r1" x="24.4" y="678.8" textLength="207.4" clip-path="url(#terminal-line-27)">The answer is 42.</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> +</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> +</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"> +</text><text class="terminal-r1" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">────────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"> +</text><text class="terminal-r2" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"> +</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"> +</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"> +</text><text class="terminal-r1" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)"> +</text><text class="terminal-r5" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r5" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text> + </g> + </g> +</svg> diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_session_picker/test_snapshot_session_picker_header.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_session_picker/test_snapshot_session_picker_header.svg index c37a0dd..e124c71 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_session_picker/test_snapshot_session_picker_header.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_session_picker/test_snapshot_session_picker_header.svg @@ -38,7 +38,6 @@ .terminal-r4 { fill: #868887 } .terminal-r5 { fill: #7b7e82;font-weight: bold } .terminal-r6 { fill: #4b4e55;font-weight: bold } -.terminal-r7 { fill: #68a0b3;font-weight: bold } </style> <defs> @@ -160,7 +159,7 @@ </g> <g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)"> - <rect fill="#c5c8c6" x="36.6" y="733.5" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#c5c8c6" x="158.6" y="733.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#c5c8c6" x="183" y="733.5" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#c5c8c6" x="256.2" y="733.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#c5c8c6" x="280.6" y="733.5" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#c5c8c6" x="402.6" y="733.5" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#c5c8c6" x="695.4" y="733.5" width="488" height="24.65" shape-rendering="crispEdges"/> + <rect fill="#c5c8c6" x="36.6" y="733.5" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#c5c8c6" x="158.6" y="733.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#c5c8c6" x="183" y="733.5" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#c5c8c6" x="305" y="733.5" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#c5c8c6" x="597.8" y="733.5" width="585.6" height="24.65" shape-rendering="crispEdges"/> <g class="terminal-matrix"> <text class="terminal-r1" x="1220" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"> </text><text class="terminal-r1" x="1220" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"> @@ -190,10 +189,10 @@ </text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> </text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> </text><text class="terminal-r1" x="0" y="678.8" textLength="1220" clip-path="url(#terminal-line-27)">┌──────────────────────────────────────────────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> -</text><text class="terminal-r1" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r3" x="24.4" y="703.2" textLength="73.2" clip-path="url(#terminal-line-28)">local </text><text class="terminal-r4" x="97.6" y="703.2" textLength="158.6" clip-path="url(#terminal-line-28)">/test/workdir</text><text class="terminal-r4" x="256.2" y="703.2" textLength="61" clip-path="url(#terminal-line-28)">  ·  </text><text class="terminal-r3" x="317.2" y="703.2" textLength="85.4" clip-path="url(#terminal-line-28)">remote </text><text class="terminal-r4" x="402.6" y="703.2" textLength="134.2" clip-path="url(#terminal-line-28)">all folders</text><text class="terminal-r1" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> +</text><text class="terminal-r1" x="0" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r3" x="24.4" y="703.2" textLength="73.2" clip-path="url(#terminal-line-28)">local </text><text class="terminal-r4" x="97.6" y="703.2" textLength="158.6" clip-path="url(#terminal-line-28)">/test/workdir</text><text class="terminal-r1" x="1207.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">│</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> </text><text class="terminal-r1" x="0" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r1" x="1207.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">│</text><text class="terminal-r1" x="1220" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"> -</text><text class="terminal-r1" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r5" x="36.6" y="752" textLength="122" clip-path="url(#terminal-line-30)">unknown   </text><text class="terminal-r7" x="183" y="752" textLength="73.2" clip-path="url(#terminal-line-30)">local </text><text class="terminal-r5" x="280.6" y="752" textLength="122" clip-path="url(#terminal-line-30)">local-se  </text><text class="terminal-r6" x="402.6" y="752" textLength="292.8" clip-path="url(#terminal-line-30)">Refactor the auth module</text><text class="terminal-r1" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r1" x="1220" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"> -</text><text class="terminal-r1" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r4" x="36.6" y="776.4" textLength="122" clip-path="url(#terminal-line-31)">unknown   </text><text class="terminal-r3" x="183" y="776.4" textLength="73.2" clip-path="url(#terminal-line-31)">remote</text><text class="terminal-r4" x="280.6" y="776.4" textLength="122" clip-path="url(#terminal-line-31)">ion-0002  </text><text class="terminal-r1" x="402.6" y="776.4" textLength="231.8" clip-path="url(#terminal-line-31)">Vibe Code (running)</text><text class="terminal-r1" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1220" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"> +</text><text class="terminal-r1" x="0" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r5" x="36.6" y="752" textLength="122" clip-path="url(#terminal-line-30)">unknown   </text><text class="terminal-r5" x="183" y="752" textLength="122" clip-path="url(#terminal-line-30)">local-se  </text><text class="terminal-r6" x="305" y="752" textLength="292.8" clip-path="url(#terminal-line-30)">Refactor the auth module</text><text class="terminal-r1" x="1207.8" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">│</text><text class="terminal-r1" x="1220" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"> +</text><text class="terminal-r1" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r4" x="36.6" y="776.4" textLength="122" clip-path="url(#terminal-line-31)">unknown   </text><text class="terminal-r4" x="183" y="776.4" textLength="122" clip-path="url(#terminal-line-31)">local-se  </text><text class="terminal-r1" x="305" y="776.4" textLength="317.2" clip-path="url(#terminal-line-31)">Add unit tests for the API</text><text class="terminal-r1" x="1207.8" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">│</text><text class="terminal-r1" x="1220" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"> </text><text class="terminal-r1" x="0" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1207.8" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">│</text><text class="terminal-r1" x="1220" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"> </text><text class="terminal-r1" x="0" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r4" x="24.4" y="825.2" textLength="573.4" clip-path="url(#terminal-line-33)">↑↓ Navigate  Enter Select  D Delete  Esc Cancel</text><text class="terminal-r1" x="1207.8" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">│</text><text class="terminal-r1" x="1220" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"> </text><text class="terminal-r1" x="0" y="849.6" textLength="1220" clip-path="url(#terminal-line-34)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)"> diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_hidden_for_non_pro_account.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_hidden_for_non_pro_account.svg index 178ffc6..640375b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_hidden_for_non_pro_account.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_hidden_for_non_pro_account.svg @@ -33,9 +33,9 @@ } .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #608ab1 } -.terminal-r3 { fill: #98a84b;font-weight: bold } -.terminal-r4 { fill: #292929 } +.terminal-r2 { fill: #98a84b;font-weight: bold } +.terminal-r3 { fill: #292929 } +.terminal-r4 { fill: #608ab1 } .terminal-r5 { fill: #ff8205;font-weight: bold } .terminal-r6 { fill: #868887 } </style> @@ -197,46 +197,46 @@ <g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)"> <rect fill="#4b4e55" x="1451.8" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="489.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="513.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="538.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="562.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="733.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="757.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="782.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="806.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="831.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="855.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="879.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="904.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="928.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="953.1" width="12.2" height="24.65" shape-rendering="crispEdges"/> <g class="terminal-matrix"> - <text class="terminal-r1" x="24.4" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">⎢</text><text class="terminal-r2" x="48.8" y="20" textLength="219.6" clip-path="url(#terminal-line-0)">Keyboard Shortcuts</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"> -</text><text class="terminal-r1" x="24.4" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">⎢</text><text class="terminal-r1" x="48.8" y="44.4" textLength="24.4" clip-path="url(#terminal-line-1)">• </text><text class="terminal-r3" x="73.2" y="44.4" textLength="61" clip-path="url(#terminal-line-1)">Enter</text><text class="terminal-r1" x="134.2" y="44.4" textLength="183" clip-path="url(#terminal-line-1)"> Submit message</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"> -</text><text class="terminal-r1" x="24.4" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">⎢</text><text class="terminal-r1" x="48.8" y="68.8" textLength="24.4" clip-path="url(#terminal-line-2)">• </text><text class="terminal-r3" x="73.2" y="68.8" textLength="73.2" clip-path="url(#terminal-line-2)">Ctrl+J</text><text class="terminal-r1" x="146.4" y="68.8" textLength="36.6" clip-path="url(#terminal-line-2)"> / </text><text class="terminal-r3" x="183" y="68.8" textLength="134.2" clip-path="url(#terminal-line-2)">Shift+Enter</text><text class="terminal-r1" x="317.2" y="68.8" textLength="183" clip-path="url(#terminal-line-2)"> Insert newline</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"> -</text><text class="terminal-r1" x="24.4" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">⎢</text><text class="terminal-r1" x="48.8" y="93.2" textLength="24.4" clip-path="url(#terminal-line-3)">• </text><text class="terminal-r3" x="73.2" y="93.2" textLength="73.2" clip-path="url(#terminal-line-3)">Escape</text><text class="terminal-r1" x="146.4" y="93.2" textLength="402.6" clip-path="url(#terminal-line-3)"> Interrupt agent or close dialogs</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"> -</text><text class="terminal-r1" x="24.4" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">⎢</text><text class="terminal-r1" x="48.8" y="117.6" textLength="24.4" clip-path="url(#terminal-line-4)">• </text><text class="terminal-r3" x="73.2" y="117.6" textLength="73.2" clip-path="url(#terminal-line-4)">Ctrl+C</text><text class="terminal-r1" x="146.4" y="117.6" textLength="463.6" clip-path="url(#terminal-line-4)"> Quit (or clear input if text present)</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"> -</text><text class="terminal-r1" x="24.4" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">⎢</text><text class="terminal-r1" x="48.8" y="142" textLength="24.4" clip-path="url(#terminal-line-5)">• </text><text class="terminal-r3" x="73.2" y="142" textLength="73.2" clip-path="url(#terminal-line-5)">Ctrl+G</text><text class="terminal-r1" x="146.4" y="142" textLength="366" clip-path="url(#terminal-line-5)"> Edit input in external editor</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"> -</text><text class="terminal-r1" x="24.4" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">⎢</text><text class="terminal-r1" x="48.8" y="166.4" textLength="24.4" clip-path="url(#terminal-line-6)">• </text><text class="terminal-r3" x="73.2" y="166.4" textLength="73.2" clip-path="url(#terminal-line-6)">Ctrl+O</text><text class="terminal-r1" x="146.4" y="166.4" textLength="292.8" clip-path="url(#terminal-line-6)"> Toggle tool output view</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"> -</text><text class="terminal-r1" x="24.4" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">⎢</text><text class="terminal-r1" x="48.8" y="190.8" textLength="24.4" clip-path="url(#terminal-line-7)">• </text><text class="terminal-r3" x="73.2" y="190.8" textLength="109.8" clip-path="url(#terminal-line-7)">Shift+Tab</text><text class="terminal-r1" x="183" y="190.8" textLength="512.4" clip-path="url(#terminal-line-7)"> Cycle through agents (default, plan, ...)</text><text class="terminal-r2" x="1451.8" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">▆</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"> -</text><text class="terminal-r1" x="24.4" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">⎢</text><text class="terminal-r1" x="48.8" y="215.2" textLength="24.4" clip-path="url(#terminal-line-8)">• </text><text class="terminal-r3" x="73.2" y="215.2" textLength="73.2" clip-path="url(#terminal-line-8)">Alt+↑↓</text><text class="terminal-r1" x="146.4" y="215.2" textLength="36.6" clip-path="url(#terminal-line-8)"> / </text><text class="terminal-r3" x="183" y="215.2" textLength="97.6" clip-path="url(#terminal-line-8)">Ctrl+P/N</text><text class="terminal-r1" x="280.6" y="215.2" textLength="390.4" clip-path="url(#terminal-line-8)"> Rewind to previous/next message</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"> -</text><text class="terminal-r1" x="24.4" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">⎢</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"> -</text><text class="terminal-r1" x="24.4" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">⎢</text><text class="terminal-r2" x="48.8" y="264" textLength="195.2" clip-path="url(#terminal-line-10)">Special Features</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"> -</text><text class="terminal-r1" x="24.4" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">⎢</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"> -</text><text class="terminal-r1" x="24.4" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">⎢</text><text class="terminal-r1" x="48.8" y="312.8" textLength="24.4" clip-path="url(#terminal-line-12)">• </text><text class="terminal-r3" x="73.2" y="312.8" textLength="122" clip-path="url(#terminal-line-12)">!<command></text><text class="terminal-r1" x="195.2" y="312.8" textLength="366" clip-path="url(#terminal-line-12)"> Execute bash command directly</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"> -</text><text class="terminal-r1" x="24.4" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">⎢</text><text class="terminal-r1" x="48.8" y="337.2" textLength="24.4" clip-path="url(#terminal-line-13)">• </text><text class="terminal-r3" x="73.2" y="337.2" textLength="170.8" clip-path="url(#terminal-line-13)">@path/to/file/</text><text class="terminal-r1" x="244" y="337.2" textLength="305" clip-path="url(#terminal-line-13)"> Autocompletes file paths</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"> -</text><text class="terminal-r1" x="24.4" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">⎢</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"> -</text><text class="terminal-r1" x="24.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">⎢</text><text class="terminal-r2" x="48.8" y="386" textLength="97.6" clip-path="url(#terminal-line-15)">Commands</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"> -</text><text class="terminal-r1" x="24.4" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">⎢</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"> -</text><text class="terminal-r1" x="24.4" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">⎢</text><text class="terminal-r1" x="48.8" y="434.8" textLength="24.4" clip-path="url(#terminal-line-17)">• </text><text class="terminal-r3" x="73.2" y="434.8" textLength="61" clip-path="url(#terminal-line-17)">/help</text><text class="terminal-r1" x="134.2" y="434.8" textLength="231.8" clip-path="url(#terminal-line-17)">: Show help message</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"> -</text><text class="terminal-r1" x="24.4" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">⎢</text><text class="terminal-r1" x="48.8" y="459.2" textLength="24.4" clip-path="url(#terminal-line-18)">• </text><text class="terminal-r3" x="73.2" y="459.2" textLength="85.4" clip-path="url(#terminal-line-18)">/config</text><text class="terminal-r1" x="158.6" y="459.2" textLength="268.4" clip-path="url(#terminal-line-18)">: Edit config settings</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"> -</text><text class="terminal-r1" x="24.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">⎢</text><text class="terminal-r1" x="48.8" y="483.6" textLength="24.4" clip-path="url(#terminal-line-19)">• </text><text class="terminal-r3" x="73.2" y="483.6" textLength="73.2" clip-path="url(#terminal-line-19)">/model</text><text class="terminal-r1" x="146.4" y="483.6" textLength="256.2" clip-path="url(#terminal-line-19)">: Select active model</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"> -</text><text class="terminal-r1" x="24.4" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">⎢</text><text class="terminal-r1" x="48.8" y="508" textLength="24.4" clip-path="url(#terminal-line-20)">• </text><text class="terminal-r3" x="73.2" y="508" textLength="109.8" clip-path="url(#terminal-line-20)">/thinking</text><text class="terminal-r1" x="183" y="508" textLength="280.6" clip-path="url(#terminal-line-20)">: Select thinking level</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"> -</text><text class="terminal-r1" x="24.4" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">⎢</text><text class="terminal-r1" x="48.8" y="532.4" textLength="24.4" clip-path="url(#terminal-line-21)">• </text><text class="terminal-r3" x="73.2" y="532.4" textLength="85.4" clip-path="url(#terminal-line-21)">/reload</text><text class="terminal-r1" x="158.6" y="532.4" textLength="780.8" clip-path="url(#terminal-line-21)">: Reload configuration, agent instructions, and skills from disk</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"> -</text><text class="terminal-r1" x="24.4" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">⎢</text><text class="terminal-r1" x="48.8" y="556.8" textLength="24.4" clip-path="url(#terminal-line-22)">• </text><text class="terminal-r3" x="73.2" y="556.8" textLength="73.2" clip-path="url(#terminal-line-22)">/clear</text><text class="terminal-r1" x="146.4" y="556.8" textLength="341.6" clip-path="url(#terminal-line-22)">: Clear conversation history</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"> -</text><text class="terminal-r1" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">⎢</text><text class="terminal-r1" x="48.8" y="581.2" textLength="24.4" clip-path="url(#terminal-line-23)">• </text><text class="terminal-r3" x="73.2" y="581.2" textLength="61" clip-path="url(#terminal-line-23)">/copy</text><text class="terminal-r1" x="134.2" y="581.2" textLength="561.2" clip-path="url(#terminal-line-23)">: Copy the last agent message to the clipboard</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"> -</text><text class="terminal-r1" x="24.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">⎢</text><text class="terminal-r1" x="48.8" y="605.6" textLength="24.4" clip-path="url(#terminal-line-24)">• </text><text class="terminal-r3" x="73.2" y="605.6" textLength="48.8" clip-path="url(#terminal-line-24)">/log</text><text class="terminal-r1" x="122" y="605.6" textLength="524.6" clip-path="url(#terminal-line-24)">: Show path to current interaction log file</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"> -</text><text class="terminal-r1" x="24.4" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">⎢</text><text class="terminal-r1" x="48.8" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">• </text><text class="terminal-r3" x="73.2" y="630" textLength="73.2" clip-path="url(#terminal-line-25)">/debug</text><text class="terminal-r1" x="146.4" y="630" textLength="268.4" clip-path="url(#terminal-line-25)">: Toggle debug console</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> -</text><text class="terminal-r1" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">⎢</text><text class="terminal-r1" x="48.8" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">• </text><text class="terminal-r3" x="73.2" y="654.4" textLength="97.6" clip-path="url(#terminal-line-26)">/compact</text><text class="terminal-r1" x="170.8" y="654.4" textLength="1171.2" clip-path="url(#terminal-line-26)">: Compact conversation history by summarizing. Optionally pass instructions to guide the summary</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> -</text><text class="terminal-r1" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">⎢</text><text class="terminal-r1" x="48.8" y="678.8" textLength="24.4" clip-path="url(#terminal-line-27)">• </text><text class="terminal-r3" x="73.2" y="678.8" textLength="61" clip-path="url(#terminal-line-27)">/exit</text><text class="terminal-r1" x="134.2" y="678.8" textLength="24.4" clip-path="url(#terminal-line-27)">, </text><text class="terminal-r3" x="158.6" y="678.8" textLength="24.4" clip-path="url(#terminal-line-27)">:q</text><text class="terminal-r1" x="183" y="678.8" textLength="24.4" clip-path="url(#terminal-line-27)">, </text><text class="terminal-r3" x="207.4" y="678.8" textLength="61" clip-path="url(#terminal-line-27)">:quit</text><text class="terminal-r1" x="268.4" y="678.8" textLength="24.4" clip-path="url(#terminal-line-27)">, </text><text class="terminal-r3" x="292.8" y="678.8" textLength="48.8" clip-path="url(#terminal-line-27)">exit</text><text class="terminal-r1" x="341.6" y="678.8" textLength="24.4" clip-path="url(#terminal-line-27)">, </text><text class="terminal-r3" x="366" y="678.8" textLength="48.8" clip-path="url(#terminal-line-27)">quit</text><text class="terminal-r1" x="414.8" y="678.8" textLength="268.4" clip-path="url(#terminal-line-27)">: Exit the application</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> -</text><text class="terminal-r1" x="24.4" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">⎢</text><text class="terminal-r1" x="48.8" y="703.2" textLength="24.4" clip-path="url(#terminal-line-28)">• </text><text class="terminal-r3" x="73.2" y="703.2" textLength="85.4" clip-path="url(#terminal-line-28)">/status</text><text class="terminal-r1" x="158.6" y="703.2" textLength="317.2" clip-path="url(#terminal-line-28)">: Display agent statistics</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> -</text><text class="terminal-r1" x="24.4" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">⎢</text><text class="terminal-r1" x="48.8" y="727.6" textLength="24.4" clip-path="url(#terminal-line-29)">• </text><text class="terminal-r3" x="73.2" y="727.6" textLength="146.4" clip-path="url(#terminal-line-29)">/proxy-setup</text><text class="terminal-r1" x="219.6" y="727.6" textLength="561.2" clip-path="url(#terminal-line-29)">: Configure proxy and SSL certificate settings</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"> -</text><text class="terminal-r1" x="24.4" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">⎢</text><text class="terminal-r1" x="48.8" y="752" textLength="24.4" clip-path="url(#terminal-line-30)">• </text><text class="terminal-r3" x="73.2" y="752" textLength="109.8" clip-path="url(#terminal-line-30)">/continue</text><text class="terminal-r1" x="183" y="752" textLength="24.4" clip-path="url(#terminal-line-30)">, </text><text class="terminal-r3" x="207.4" y="752" textLength="85.4" clip-path="url(#terminal-line-30)">/resume</text><text class="terminal-r1" x="292.8" y="752" textLength="512.4" clip-path="url(#terminal-line-30)">: Browse, resume, or delete saved sessions</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"> -</text><text class="terminal-r1" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">⎢</text><text class="terminal-r1" x="48.8" y="776.4" textLength="24.4" clip-path="url(#terminal-line-31)">• </text><text class="terminal-r3" x="73.2" y="776.4" textLength="85.4" clip-path="url(#terminal-line-31)">/rename</text><text class="terminal-r1" x="158.6" y="776.4" textLength="341.6" clip-path="url(#terminal-line-31)">: Rename the current session</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"> -</text><text class="terminal-r1" x="24.4" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">⎢</text><text class="terminal-r1" x="48.8" y="800.8" textLength="24.4" clip-path="url(#terminal-line-32)">• </text><text class="terminal-r3" x="73.2" y="800.8" textLength="134.2" clip-path="url(#terminal-line-32)">/connectors</text><text class="terminal-r1" x="207.4" y="800.8" textLength="24.4" clip-path="url(#terminal-line-32)">, </text><text class="terminal-r3" x="231.8" y="800.8" textLength="48.8" clip-path="url(#terminal-line-32)">/mcp</text><text class="terminal-r1" x="280.6" y="800.8" textLength="939.4" clip-path="url(#terminal-line-32)">: Display available MCP servers and connectors. Pass a name to list its tools</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"> -</text><text class="terminal-r1" x="24.4" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">⎢</text><text class="terminal-r1" x="48.8" y="825.2" textLength="24.4" clip-path="url(#terminal-line-33)">• </text><text class="terminal-r3" x="73.2" y="825.2" textLength="73.2" clip-path="url(#terminal-line-33)">/voice</text><text class="terminal-r1" x="146.4" y="825.2" textLength="317.2" clip-path="url(#terminal-line-33)">: Configure voice settings</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"> -</text><text class="terminal-r1" x="24.4" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">⎢</text><text class="terminal-r1" x="48.8" y="849.6" textLength="24.4" clip-path="url(#terminal-line-34)">• </text><text class="terminal-r3" x="73.2" y="849.6" textLength="122" clip-path="url(#terminal-line-34)">/leanstall</text><text class="terminal-r1" x="195.2" y="849.6" textLength="463.6" clip-path="url(#terminal-line-34)">: Install the Lean 4 agent (leanstral)</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)"> -</text><text class="terminal-r1" x="24.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)">⎢</text><text class="terminal-r1" x="48.8" y="874" textLength="24.4" clip-path="url(#terminal-line-35)">• </text><text class="terminal-r3" x="73.2" y="874" textLength="146.4" clip-path="url(#terminal-line-35)">/unleanstall</text><text class="terminal-r1" x="219.6" y="874" textLength="341.6" clip-path="url(#terminal-line-35)">: Uninstall the Lean 4 agent</text><text class="terminal-r1" x="1464" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"> -</text><text class="terminal-r1" x="24.4" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)">⎢</text><text class="terminal-r1" x="48.8" y="898.4" textLength="24.4" clip-path="url(#terminal-line-36)">• </text><text class="terminal-r3" x="73.2" y="898.4" textLength="85.4" clip-path="url(#terminal-line-36)">/rewind</text><text class="terminal-r1" x="158.6" y="898.4" textLength="366" clip-path="url(#terminal-line-36)">: Rewind to a previous message</text><text class="terminal-r1" x="1464" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)"> -</text><text class="terminal-r1" x="24.4" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)">⎢</text><text class="terminal-r1" x="48.8" y="922.8" textLength="24.4" clip-path="url(#terminal-line-37)">• </text><text class="terminal-r3" x="73.2" y="922.8" textLength="61" clip-path="url(#terminal-line-37)">/loop</text><text class="terminal-r1" x="134.2" y="922.8" textLength="427" clip-path="url(#terminal-line-37)">: Schedule a recurring prompt. Use </text><text class="terminal-r3" x="561.2" y="922.8" textLength="305" clip-path="url(#terminal-line-37)">/loop <interval> <prompt></text><text class="terminal-r1" x="866.2" y="922.8" textLength="24.4" clip-path="url(#terminal-line-37)">, </text><text class="terminal-r3" x="890.6" y="922.8" textLength="122" clip-path="url(#terminal-line-37)">/loop list</text><text class="terminal-r1" x="1012.6" y="922.8" textLength="61" clip-path="url(#terminal-line-37)">, or </text><text class="terminal-r3" x="1073.6" y="922.8" textLength="256.2" clip-path="url(#terminal-line-37)">/loop cancel <id|all></text><text class="terminal-r1" x="1464" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)"> -</text><text class="terminal-r1" x="24.4" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)">⎢</text><text class="terminal-r1" x="48.8" y="947.2" textLength="24.4" clip-path="url(#terminal-line-38)">• </text><text class="terminal-r3" x="73.2" y="947.2" textLength="183" clip-path="url(#terminal-line-38)">/data-retention</text><text class="terminal-r1" x="256.2" y="947.2" textLength="402.6" clip-path="url(#terminal-line-38)">: Show data retention information</text><text class="terminal-r1" x="1464" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)"> -</text><text class="terminal-r1" x="24.4" y="971.6" textLength="12.2" clip-path="url(#terminal-line-39)">⎣</text><text class="terminal-r1" x="48.8" y="971.6" textLength="24.4" clip-path="url(#terminal-line-39)">• </text><text class="terminal-r3" x="73.2" y="971.6" textLength="73.2" clip-path="url(#terminal-line-39)">/theme</text><text class="terminal-r1" x="146.4" y="971.6" textLength="170.8" clip-path="url(#terminal-line-39)">: Select theme</text><text class="terminal-r1" x="1464" y="971.6" textLength="12.2" clip-path="url(#terminal-line-39)"> + <text class="terminal-r1" x="24.4" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">⎢</text><text class="terminal-r1" x="48.8" y="20" textLength="24.4" clip-path="url(#terminal-line-0)">• </text><text class="terminal-r2" x="73.2" y="20" textLength="61" clip-path="url(#terminal-line-0)">Enter</text><text class="terminal-r1" x="134.2" y="20" textLength="183" clip-path="url(#terminal-line-0)"> Submit message</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"> +</text><text class="terminal-r1" x="24.4" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">⎢</text><text class="terminal-r1" x="48.8" y="44.4" textLength="24.4" clip-path="url(#terminal-line-1)">• </text><text class="terminal-r2" x="73.2" y="44.4" textLength="73.2" clip-path="url(#terminal-line-1)">Ctrl+J</text><text class="terminal-r1" x="146.4" y="44.4" textLength="36.6" clip-path="url(#terminal-line-1)"> / </text><text class="terminal-r2" x="183" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">Shift+Enter</text><text class="terminal-r1" x="317.2" y="44.4" textLength="183" clip-path="url(#terminal-line-1)"> Insert newline</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"> +</text><text class="terminal-r1" x="24.4" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">⎢</text><text class="terminal-r1" x="48.8" y="68.8" textLength="24.4" clip-path="url(#terminal-line-2)">• </text><text class="terminal-r2" x="73.2" y="68.8" textLength="73.2" clip-path="url(#terminal-line-2)">Escape</text><text class="terminal-r1" x="146.4" y="68.8" textLength="402.6" clip-path="url(#terminal-line-2)"> Interrupt agent or close dialogs</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"> +</text><text class="terminal-r1" x="24.4" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">⎢</text><text class="terminal-r1" x="48.8" y="93.2" textLength="24.4" clip-path="url(#terminal-line-3)">• </text><text class="terminal-r2" x="73.2" y="93.2" textLength="73.2" clip-path="url(#terminal-line-3)">Ctrl+C</text><text class="terminal-r1" x="146.4" y="93.2" textLength="463.6" clip-path="url(#terminal-line-3)"> Quit (or clear input if text present)</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"> +</text><text class="terminal-r1" x="24.4" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">⎢</text><text class="terminal-r1" x="48.8" y="117.6" textLength="24.4" clip-path="url(#terminal-line-4)">• </text><text class="terminal-r2" x="73.2" y="117.6" textLength="73.2" clip-path="url(#terminal-line-4)">Ctrl+G</text><text class="terminal-r1" x="146.4" y="117.6" textLength="366" clip-path="url(#terminal-line-4)"> Edit input in external editor</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"> +</text><text class="terminal-r1" x="24.4" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">⎢</text><text class="terminal-r1" x="48.8" y="142" textLength="24.4" clip-path="url(#terminal-line-5)">• </text><text class="terminal-r2" x="73.2" y="142" textLength="73.2" clip-path="url(#terminal-line-5)">Ctrl+O</text><text class="terminal-r1" x="146.4" y="142" textLength="292.8" clip-path="url(#terminal-line-5)"> Toggle tool output view</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"> +</text><text class="terminal-r1" x="24.4" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">⎢</text><text class="terminal-r1" x="48.8" y="166.4" textLength="24.4" clip-path="url(#terminal-line-6)">• </text><text class="terminal-r2" x="73.2" y="166.4" textLength="109.8" clip-path="url(#terminal-line-6)">Shift+Tab</text><text class="terminal-r1" x="183" y="166.4" textLength="512.4" clip-path="url(#terminal-line-6)"> Cycle through agents (default, plan, ...)</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"> +</text><text class="terminal-r1" x="24.4" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">⎢</text><text class="terminal-r1" x="48.8" y="190.8" textLength="24.4" clip-path="url(#terminal-line-7)">• </text><text class="terminal-r2" x="73.2" y="190.8" textLength="73.2" clip-path="url(#terminal-line-7)">Alt+↑↓</text><text class="terminal-r1" x="146.4" y="190.8" textLength="36.6" clip-path="url(#terminal-line-7)"> / </text><text class="terminal-r2" x="183" y="190.8" textLength="97.6" clip-path="url(#terminal-line-7)">Ctrl+P/N</text><text class="terminal-r1" x="280.6" y="190.8" textLength="390.4" clip-path="url(#terminal-line-7)"> Rewind to previous/next message</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"> +</text><text class="terminal-r1" x="24.4" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">⎢</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"> +</text><text class="terminal-r1" x="24.4" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">⎢</text><text class="terminal-r4" x="48.8" y="239.6" textLength="195.2" clip-path="url(#terminal-line-9)">Special Features</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"> +</text><text class="terminal-r1" x="24.4" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">⎢</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"> +</text><text class="terminal-r1" x="24.4" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">⎢</text><text class="terminal-r1" x="48.8" y="288.4" textLength="24.4" clip-path="url(#terminal-line-11)">• </text><text class="terminal-r2" x="73.2" y="288.4" textLength="122" clip-path="url(#terminal-line-11)">!<command></text><text class="terminal-r1" x="195.2" y="288.4" textLength="366" clip-path="url(#terminal-line-11)"> Execute bash command directly</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"> +</text><text class="terminal-r1" x="24.4" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">⎢</text><text class="terminal-r1" x="48.8" y="312.8" textLength="24.4" clip-path="url(#terminal-line-12)">• </text><text class="terminal-r2" x="73.2" y="312.8" textLength="170.8" clip-path="url(#terminal-line-12)">@path/to/file/</text><text class="terminal-r1" x="244" y="312.8" textLength="305" clip-path="url(#terminal-line-12)"> Autocompletes file paths</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"> +</text><text class="terminal-r1" x="24.4" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">⎢</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"> +</text><text class="terminal-r1" x="24.4" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">⎢</text><text class="terminal-r4" x="48.8" y="361.6" textLength="97.6" clip-path="url(#terminal-line-14)">Commands</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"> +</text><text class="terminal-r1" x="24.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">⎢</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"> +</text><text class="terminal-r1" x="24.4" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">⎢</text><text class="terminal-r1" x="48.8" y="410.4" textLength="24.4" clip-path="url(#terminal-line-16)">• </text><text class="terminal-r2" x="73.2" y="410.4" textLength="61" clip-path="url(#terminal-line-16)">/help</text><text class="terminal-r1" x="134.2" y="410.4" textLength="231.8" clip-path="url(#terminal-line-16)">: Show help message</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"> +</text><text class="terminal-r1" x="24.4" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">⎢</text><text class="terminal-r1" x="48.8" y="434.8" textLength="24.4" clip-path="url(#terminal-line-17)">• </text><text class="terminal-r2" x="73.2" y="434.8" textLength="85.4" clip-path="url(#terminal-line-17)">/config</text><text class="terminal-r1" x="158.6" y="434.8" textLength="268.4" clip-path="url(#terminal-line-17)">: Edit config settings</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"> +</text><text class="terminal-r1" x="24.4" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">⎢</text><text class="terminal-r1" x="48.8" y="459.2" textLength="24.4" clip-path="url(#terminal-line-18)">• </text><text class="terminal-r2" x="73.2" y="459.2" textLength="73.2" clip-path="url(#terminal-line-18)">/model</text><text class="terminal-r1" x="146.4" y="459.2" textLength="256.2" clip-path="url(#terminal-line-18)">: Select active model</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"> +</text><text class="terminal-r1" x="24.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">⎢</text><text class="terminal-r1" x="48.8" y="483.6" textLength="24.4" clip-path="url(#terminal-line-19)">• </text><text class="terminal-r2" x="73.2" y="483.6" textLength="109.8" clip-path="url(#terminal-line-19)">/thinking</text><text class="terminal-r1" x="183" y="483.6" textLength="280.6" clip-path="url(#terminal-line-19)">: Select thinking level</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"> +</text><text class="terminal-r1" x="24.4" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">⎢</text><text class="terminal-r1" x="48.8" y="508" textLength="24.4" clip-path="url(#terminal-line-20)">• </text><text class="terminal-r2" x="73.2" y="508" textLength="85.4" clip-path="url(#terminal-line-20)">/reload</text><text class="terminal-r1" x="158.6" y="508" textLength="780.8" clip-path="url(#terminal-line-20)">: Reload configuration, agent instructions, and skills from disk</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"> +</text><text class="terminal-r1" x="24.4" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">⎢</text><text class="terminal-r1" x="48.8" y="532.4" textLength="24.4" clip-path="url(#terminal-line-21)">• </text><text class="terminal-r2" x="73.2" y="532.4" textLength="73.2" clip-path="url(#terminal-line-21)">/clear</text><text class="terminal-r1" x="146.4" y="532.4" textLength="341.6" clip-path="url(#terminal-line-21)">: Clear conversation history</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"> +</text><text class="terminal-r1" x="24.4" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">⎢</text><text class="terminal-r1" x="48.8" y="556.8" textLength="24.4" clip-path="url(#terminal-line-22)">• </text><text class="terminal-r2" x="73.2" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/copy</text><text class="terminal-r1" x="134.2" y="556.8" textLength="561.2" clip-path="url(#terminal-line-22)">: Copy the last agent message to the clipboard</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"> +</text><text class="terminal-r1" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">⎢</text><text class="terminal-r1" x="48.8" y="581.2" textLength="24.4" clip-path="url(#terminal-line-23)">• </text><text class="terminal-r2" x="73.2" y="581.2" textLength="48.8" clip-path="url(#terminal-line-23)">/log</text><text class="terminal-r1" x="122" y="581.2" textLength="524.6" clip-path="url(#terminal-line-23)">: Show path to current interaction log file</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"> +</text><text class="terminal-r1" x="24.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">⎢</text><text class="terminal-r1" x="48.8" y="605.6" textLength="24.4" clip-path="url(#terminal-line-24)">• </text><text class="terminal-r2" x="73.2" y="605.6" textLength="73.2" clip-path="url(#terminal-line-24)">/debug</text><text class="terminal-r1" x="146.4" y="605.6" textLength="268.4" clip-path="url(#terminal-line-24)">: Toggle debug console</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"> +</text><text class="terminal-r1" x="24.4" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">⎢</text><text class="terminal-r1" x="48.8" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">• </text><text class="terminal-r2" x="73.2" y="630" textLength="97.6" clip-path="url(#terminal-line-25)">/compact</text><text class="terminal-r1" x="170.8" y="630" textLength="1171.2" clip-path="url(#terminal-line-25)">: Compact conversation history by summarizing. Optionally pass instructions to guide the summary</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> +</text><text class="terminal-r1" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">⎢</text><text class="terminal-r1" x="48.8" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">• </text><text class="terminal-r2" x="73.2" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">/exit</text><text class="terminal-r1" x="134.2" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">, </text><text class="terminal-r2" x="158.6" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">:q</text><text class="terminal-r1" x="183" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">, </text><text class="terminal-r2" x="207.4" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">:quit</text><text class="terminal-r1" x="268.4" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">, </text><text class="terminal-r2" x="292.8" y="654.4" textLength="48.8" clip-path="url(#terminal-line-26)">exit</text><text class="terminal-r1" x="341.6" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">, </text><text class="terminal-r2" x="366" y="654.4" textLength="48.8" clip-path="url(#terminal-line-26)">quit</text><text class="terminal-r1" x="414.8" y="654.4" textLength="268.4" clip-path="url(#terminal-line-26)">: Exit the application</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> +</text><text class="terminal-r1" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">⎢</text><text class="terminal-r1" x="48.8" y="678.8" textLength="24.4" clip-path="url(#terminal-line-27)">• </text><text class="terminal-r2" x="73.2" y="678.8" textLength="85.4" clip-path="url(#terminal-line-27)">/status</text><text class="terminal-r1" x="158.6" y="678.8" textLength="317.2" clip-path="url(#terminal-line-27)">: Display agent statistics</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> +</text><text class="terminal-r1" x="24.4" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">⎢</text><text class="terminal-r1" x="48.8" y="703.2" textLength="24.4" clip-path="url(#terminal-line-28)">• </text><text class="terminal-r2" x="73.2" y="703.2" textLength="146.4" clip-path="url(#terminal-line-28)">/proxy-setup</text><text class="terminal-r1" x="219.6" y="703.2" textLength="561.2" clip-path="url(#terminal-line-28)">: Configure proxy and SSL certificate settings</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> +</text><text class="terminal-r1" x="24.4" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">⎢</text><text class="terminal-r1" x="48.8" y="727.6" textLength="24.4" clip-path="url(#terminal-line-29)">• </text><text class="terminal-r2" x="73.2" y="727.6" textLength="109.8" clip-path="url(#terminal-line-29)">/continue</text><text class="terminal-r1" x="183" y="727.6" textLength="24.4" clip-path="url(#terminal-line-29)">, </text><text class="terminal-r2" x="207.4" y="727.6" textLength="85.4" clip-path="url(#terminal-line-29)">/resume</text><text class="terminal-r1" x="292.8" y="727.6" textLength="512.4" clip-path="url(#terminal-line-29)">: Browse, resume, or delete saved sessions</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"> +</text><text class="terminal-r1" x="24.4" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">⎢</text><text class="terminal-r1" x="48.8" y="752" textLength="24.4" clip-path="url(#terminal-line-30)">• </text><text class="terminal-r2" x="73.2" y="752" textLength="85.4" clip-path="url(#terminal-line-30)">/rename</text><text class="terminal-r1" x="158.6" y="752" textLength="341.6" clip-path="url(#terminal-line-30)">: Rename the current session</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"> +</text><text class="terminal-r1" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">⎢</text><text class="terminal-r1" x="48.8" y="776.4" textLength="24.4" clip-path="url(#terminal-line-31)">• </text><text class="terminal-r2" x="73.2" y="776.4" textLength="134.2" clip-path="url(#terminal-line-31)">/connectors</text><text class="terminal-r1" x="207.4" y="776.4" textLength="24.4" clip-path="url(#terminal-line-31)">, </text><text class="terminal-r2" x="231.8" y="776.4" textLength="48.8" clip-path="url(#terminal-line-31)">/mcp</text><text class="terminal-r1" x="280.6" y="776.4" textLength="1061.4" clip-path="url(#terminal-line-31)">: Display available MCP servers and connectors. Pass a name to list tools; subcommands:</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"> +</text><text class="terminal-r1" x="24.4" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">⎢</text><text class="terminal-r1" x="73.2" y="800.8" textLength="280.6" clip-path="url(#terminal-line-32)">status, login , logout </text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"> +</text><text class="terminal-r1" x="24.4" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">⎢</text><text class="terminal-r1" x="48.8" y="825.2" textLength="24.4" clip-path="url(#terminal-line-33)">• </text><text class="terminal-r2" x="73.2" y="825.2" textLength="73.2" clip-path="url(#terminal-line-33)">/voice</text><text class="terminal-r1" x="146.4" y="825.2" textLength="317.2" clip-path="url(#terminal-line-33)">: Configure voice settings</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"> +</text><text class="terminal-r1" x="24.4" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">⎢</text><text class="terminal-r1" x="48.8" y="849.6" textLength="24.4" clip-path="url(#terminal-line-34)">• </text><text class="terminal-r2" x="73.2" y="849.6" textLength="122" clip-path="url(#terminal-line-34)">/leanstall</text><text class="terminal-r1" x="195.2" y="849.6" textLength="463.6" clip-path="url(#terminal-line-34)">: Install the Lean 4 agent (leanstral)</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)"> +</text><text class="terminal-r1" x="24.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)">⎢</text><text class="terminal-r1" x="48.8" y="874" textLength="24.4" clip-path="url(#terminal-line-35)">• </text><text class="terminal-r2" x="73.2" y="874" textLength="146.4" clip-path="url(#terminal-line-35)">/unleanstall</text><text class="terminal-r1" x="219.6" y="874" textLength="341.6" clip-path="url(#terminal-line-35)">: Uninstall the Lean 4 agent</text><text class="terminal-r1" x="1464" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"> +</text><text class="terminal-r1" x="24.4" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)">⎢</text><text class="terminal-r1" x="48.8" y="898.4" textLength="24.4" clip-path="url(#terminal-line-36)">• </text><text class="terminal-r2" x="73.2" y="898.4" textLength="85.4" clip-path="url(#terminal-line-36)">/rewind</text><text class="terminal-r1" x="158.6" y="898.4" textLength="366" clip-path="url(#terminal-line-36)">: Rewind to a previous message</text><text class="terminal-r1" x="1464" y="898.4" textLength="12.2" clip-path="url(#terminal-line-36)"> +</text><text class="terminal-r1" x="24.4" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)">⎢</text><text class="terminal-r1" x="48.8" y="922.8" textLength="24.4" clip-path="url(#terminal-line-37)">• </text><text class="terminal-r2" x="73.2" y="922.8" textLength="61" clip-path="url(#terminal-line-37)">/loop</text><text class="terminal-r1" x="134.2" y="922.8" textLength="427" clip-path="url(#terminal-line-37)">: Schedule a recurring prompt. Use </text><text class="terminal-r2" x="561.2" y="922.8" textLength="305" clip-path="url(#terminal-line-37)">/loop <interval> <prompt></text><text class="terminal-r1" x="866.2" y="922.8" textLength="24.4" clip-path="url(#terminal-line-37)">, </text><text class="terminal-r2" x="890.6" y="922.8" textLength="122" clip-path="url(#terminal-line-37)">/loop list</text><text class="terminal-r1" x="1012.6" y="922.8" textLength="61" clip-path="url(#terminal-line-37)">, or </text><text class="terminal-r2" x="1073.6" y="922.8" textLength="256.2" clip-path="url(#terminal-line-37)">/loop cancel <id|all></text><text class="terminal-r1" x="1464" y="922.8" textLength="12.2" clip-path="url(#terminal-line-37)"> +</text><text class="terminal-r1" x="24.4" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)">⎢</text><text class="terminal-r1" x="48.8" y="947.2" textLength="24.4" clip-path="url(#terminal-line-38)">• </text><text class="terminal-r2" x="73.2" y="947.2" textLength="183" clip-path="url(#terminal-line-38)">/data-retention</text><text class="terminal-r1" x="256.2" y="947.2" textLength="402.6" clip-path="url(#terminal-line-38)">: Show data retention information</text><text class="terminal-r1" x="1464" y="947.2" textLength="12.2" clip-path="url(#terminal-line-38)"> +</text><text class="terminal-r1" x="24.4" y="971.6" textLength="12.2" clip-path="url(#terminal-line-39)">⎣</text><text class="terminal-r1" x="48.8" y="971.6" textLength="24.4" clip-path="url(#terminal-line-39)">• </text><text class="terminal-r2" x="73.2" y="971.6" textLength="73.2" clip-path="url(#terminal-line-39)">/theme</text><text class="terminal-r1" x="146.4" y="971.6" textLength="170.8" clip-path="url(#terminal-line-39)">: Select theme</text><text class="terminal-r1" x="1464" y="971.6" textLength="12.2" clip-path="url(#terminal-line-39)"> </text><text class="terminal-r1" x="1464" y="996" textLength="12.2" clip-path="url(#terminal-line-40)"> </text><text class="terminal-r1" x="1464" y="1020.4" textLength="12.2" clip-path="url(#terminal-line-41)"> </text><text class="terminal-r1" x="0" y="1044.8" textLength="1464" clip-path="url(#terminal-line-42)">────────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─</text><text class="terminal-r1" x="1464" y="1044.8" textLength="12.2" clip-path="url(#terminal-line-42)"> diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg index 32ecf3c..26f6f5b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg @@ -34,8 +34,8 @@ .terminal-r1 { fill: #c5c8c6 } .terminal-r2 { fill: #98a84b;font-weight: bold } -.terminal-r3 { fill: #292929 } -.terminal-r4 { fill: #608ab1 } +.terminal-r3 { fill: #608ab1 } +.terminal-r4 { fill: #292929 } .terminal-r5 { fill: #ff8205;font-weight: bold } .terminal-r6 { fill: #868887 } </style> @@ -195,41 +195,41 @@ </g> <g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)"> - <rect fill="#4b4e55" x="1451.8" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="489.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="513.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="538.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="562.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="733.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="757.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="782.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="806.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="831.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="855.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="879.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="904.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="928.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="953.1" width="12.2" height="24.65" shape-rendering="crispEdges"/> + <rect fill="#4b4e55" x="1451.8" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="74.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1451.8" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="489.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="513.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="538.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="562.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="587.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="733.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="757.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="782.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="806.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="831.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="855.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="879.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="904.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="928.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#608ab1" x="1451.8" y="953.1" width="12.2" height="24.65" shape-rendering="crispEdges"/> <g class="terminal-matrix"> - <text class="terminal-r1" x="24.4" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">⎢</text><text class="terminal-r1" x="48.8" y="20" textLength="24.4" clip-path="url(#terminal-line-0)">• </text><text class="terminal-r2" x="73.2" y="20" textLength="61" clip-path="url(#terminal-line-0)">Enter</text><text class="terminal-r1" x="134.2" y="20" textLength="183" clip-path="url(#terminal-line-0)"> Submit message</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"> -</text><text class="terminal-r1" x="24.4" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">⎢</text><text class="terminal-r1" x="48.8" y="44.4" textLength="24.4" clip-path="url(#terminal-line-1)">• </text><text class="terminal-r2" x="73.2" y="44.4" textLength="73.2" clip-path="url(#terminal-line-1)">Ctrl+J</text><text class="terminal-r1" x="146.4" y="44.4" textLength="36.6" clip-path="url(#terminal-line-1)"> / </text><text class="terminal-r2" x="183" y="44.4" textLength="134.2" clip-path="url(#terminal-line-1)">Shift+Enter</text><text class="terminal-r1" x="317.2" y="44.4" textLength="183" clip-path="url(#terminal-line-1)"> Insert newline</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"> -</text><text class="terminal-r1" x="24.4" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">⎢</text><text class="terminal-r1" x="48.8" y="68.8" textLength="24.4" clip-path="url(#terminal-line-2)">• </text><text class="terminal-r2" x="73.2" y="68.8" textLength="73.2" clip-path="url(#terminal-line-2)">Escape</text><text class="terminal-r1" x="146.4" y="68.8" textLength="402.6" clip-path="url(#terminal-line-2)"> Interrupt agent or close dialogs</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"> -</text><text class="terminal-r1" x="24.4" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">⎢</text><text class="terminal-r1" x="48.8" y="93.2" textLength="24.4" clip-path="url(#terminal-line-3)">• </text><text class="terminal-r2" x="73.2" y="93.2" textLength="73.2" clip-path="url(#terminal-line-3)">Ctrl+C</text><text class="terminal-r1" x="146.4" y="93.2" textLength="463.6" clip-path="url(#terminal-line-3)"> Quit (or clear input if text present)</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"> -</text><text class="terminal-r1" x="24.4" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">⎢</text><text class="terminal-r1" x="48.8" y="117.6" textLength="24.4" clip-path="url(#terminal-line-4)">• </text><text class="terminal-r2" x="73.2" y="117.6" textLength="73.2" clip-path="url(#terminal-line-4)">Ctrl+G</text><text class="terminal-r1" x="146.4" y="117.6" textLength="366" clip-path="url(#terminal-line-4)"> Edit input in external editor</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"> -</text><text class="terminal-r1" x="24.4" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">⎢</text><text class="terminal-r1" x="48.8" y="142" textLength="24.4" clip-path="url(#terminal-line-5)">• </text><text class="terminal-r2" x="73.2" y="142" textLength="73.2" clip-path="url(#terminal-line-5)">Ctrl+O</text><text class="terminal-r1" x="146.4" y="142" textLength="292.8" clip-path="url(#terminal-line-5)"> Toggle tool output view</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"> -</text><text class="terminal-r1" x="24.4" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">⎢</text><text class="terminal-r1" x="48.8" y="166.4" textLength="24.4" clip-path="url(#terminal-line-6)">• </text><text class="terminal-r2" x="73.2" y="166.4" textLength="109.8" clip-path="url(#terminal-line-6)">Shift+Tab</text><text class="terminal-r1" x="183" y="166.4" textLength="512.4" clip-path="url(#terminal-line-6)"> Cycle through agents (default, plan, ...)</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"> -</text><text class="terminal-r1" x="24.4" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">⎢</text><text class="terminal-r1" x="48.8" y="190.8" textLength="24.4" clip-path="url(#terminal-line-7)">• </text><text class="terminal-r2" x="73.2" y="190.8" textLength="73.2" clip-path="url(#terminal-line-7)">Alt+↑↓</text><text class="terminal-r1" x="146.4" y="190.8" textLength="36.6" clip-path="url(#terminal-line-7)"> / </text><text class="terminal-r2" x="183" y="190.8" textLength="97.6" clip-path="url(#terminal-line-7)">Ctrl+P/N</text><text class="terminal-r1" x="280.6" y="190.8" textLength="390.4" clip-path="url(#terminal-line-7)"> Rewind to previous/next message</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"> -</text><text class="terminal-r1" x="24.4" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">⎢</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"> -</text><text class="terminal-r1" x="24.4" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">⎢</text><text class="terminal-r4" x="48.8" y="239.6" textLength="195.2" clip-path="url(#terminal-line-9)">Special Features</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"> -</text><text class="terminal-r1" x="24.4" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">⎢</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"> -</text><text class="terminal-r1" x="24.4" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">⎢</text><text class="terminal-r1" x="48.8" y="288.4" textLength="24.4" clip-path="url(#terminal-line-11)">• </text><text class="terminal-r2" x="73.2" y="288.4" textLength="122" clip-path="url(#terminal-line-11)">!<command></text><text class="terminal-r1" x="195.2" y="288.4" textLength="366" clip-path="url(#terminal-line-11)"> Execute bash command directly</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"> -</text><text class="terminal-r1" x="24.4" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">⎢</text><text class="terminal-r1" x="48.8" y="312.8" textLength="24.4" clip-path="url(#terminal-line-12)">• </text><text class="terminal-r2" x="73.2" y="312.8" textLength="170.8" clip-path="url(#terminal-line-12)">@path/to/file/</text><text class="terminal-r1" x="244" y="312.8" textLength="305" clip-path="url(#terminal-line-12)"> Autocompletes file paths</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"> -</text><text class="terminal-r1" x="24.4" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">⎢</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"> -</text><text class="terminal-r1" x="24.4" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">⎢</text><text class="terminal-r4" x="48.8" y="361.6" textLength="97.6" clip-path="url(#terminal-line-14)">Commands</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"> -</text><text class="terminal-r1" x="24.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">⎢</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"> -</text><text class="terminal-r1" x="24.4" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">⎢</text><text class="terminal-r1" x="48.8" y="410.4" textLength="24.4" clip-path="url(#terminal-line-16)">• </text><text class="terminal-r2" x="73.2" y="410.4" textLength="61" clip-path="url(#terminal-line-16)">/help</text><text class="terminal-r1" x="134.2" y="410.4" textLength="231.8" clip-path="url(#terminal-line-16)">: Show help message</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"> -</text><text class="terminal-r1" x="24.4" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">⎢</text><text class="terminal-r1" x="48.8" y="434.8" textLength="24.4" clip-path="url(#terminal-line-17)">• </text><text class="terminal-r2" x="73.2" y="434.8" textLength="85.4" clip-path="url(#terminal-line-17)">/config</text><text class="terminal-r1" x="158.6" y="434.8" textLength="268.4" clip-path="url(#terminal-line-17)">: Edit config settings</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"> -</text><text class="terminal-r1" x="24.4" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">⎢</text><text class="terminal-r1" x="48.8" y="459.2" textLength="24.4" clip-path="url(#terminal-line-18)">• </text><text class="terminal-r2" x="73.2" y="459.2" textLength="73.2" clip-path="url(#terminal-line-18)">/model</text><text class="terminal-r1" x="146.4" y="459.2" textLength="256.2" clip-path="url(#terminal-line-18)">: Select active model</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"> -</text><text class="terminal-r1" x="24.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">⎢</text><text class="terminal-r1" x="48.8" y="483.6" textLength="24.4" clip-path="url(#terminal-line-19)">• </text><text class="terminal-r2" x="73.2" y="483.6" textLength="109.8" clip-path="url(#terminal-line-19)">/thinking</text><text class="terminal-r1" x="183" y="483.6" textLength="280.6" clip-path="url(#terminal-line-19)">: Select thinking level</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"> -</text><text class="terminal-r1" x="24.4" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">⎢</text><text class="terminal-r1" x="48.8" y="508" textLength="24.4" clip-path="url(#terminal-line-20)">• </text><text class="terminal-r2" x="73.2" y="508" textLength="85.4" clip-path="url(#terminal-line-20)">/reload</text><text class="terminal-r1" x="158.6" y="508" textLength="780.8" clip-path="url(#terminal-line-20)">: Reload configuration, agent instructions, and skills from disk</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"> -</text><text class="terminal-r1" x="24.4" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">⎢</text><text class="terminal-r1" x="48.8" y="532.4" textLength="24.4" clip-path="url(#terminal-line-21)">• </text><text class="terminal-r2" x="73.2" y="532.4" textLength="73.2" clip-path="url(#terminal-line-21)">/clear</text><text class="terminal-r1" x="146.4" y="532.4" textLength="341.6" clip-path="url(#terminal-line-21)">: Clear conversation history</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"> -</text><text class="terminal-r1" x="24.4" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">⎢</text><text class="terminal-r1" x="48.8" y="556.8" textLength="24.4" clip-path="url(#terminal-line-22)">• </text><text class="terminal-r2" x="73.2" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/copy</text><text class="terminal-r1" x="134.2" y="556.8" textLength="561.2" clip-path="url(#terminal-line-22)">: Copy the last agent message to the clipboard</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"> -</text><text class="terminal-r1" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">⎢</text><text class="terminal-r1" x="48.8" y="581.2" textLength="24.4" clip-path="url(#terminal-line-23)">• </text><text class="terminal-r2" x="73.2" y="581.2" textLength="48.8" clip-path="url(#terminal-line-23)">/log</text><text class="terminal-r1" x="122" y="581.2" textLength="524.6" clip-path="url(#terminal-line-23)">: Show path to current interaction log file</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"> -</text><text class="terminal-r1" x="24.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">⎢</text><text class="terminal-r1" x="48.8" y="605.6" textLength="24.4" clip-path="url(#terminal-line-24)">• </text><text class="terminal-r2" x="73.2" y="605.6" textLength="73.2" clip-path="url(#terminal-line-24)">/debug</text><text class="terminal-r1" x="146.4" y="605.6" textLength="268.4" clip-path="url(#terminal-line-24)">: Toggle debug console</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"> -</text><text class="terminal-r1" x="24.4" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">⎢</text><text class="terminal-r1" x="48.8" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">• </text><text class="terminal-r2" x="73.2" y="630" textLength="97.6" clip-path="url(#terminal-line-25)">/compact</text><text class="terminal-r1" x="170.8" y="630" textLength="1171.2" clip-path="url(#terminal-line-25)">: Compact conversation history by summarizing. Optionally pass instructions to guide the summary</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> -</text><text class="terminal-r1" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">⎢</text><text class="terminal-r1" x="48.8" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">• </text><text class="terminal-r2" x="73.2" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">/exit</text><text class="terminal-r1" x="134.2" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">, </text><text class="terminal-r2" x="158.6" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">:q</text><text class="terminal-r1" x="183" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">, </text><text class="terminal-r2" x="207.4" y="654.4" textLength="61" clip-path="url(#terminal-line-26)">:quit</text><text class="terminal-r1" x="268.4" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">, </text><text class="terminal-r2" x="292.8" y="654.4" textLength="48.8" clip-path="url(#terminal-line-26)">exit</text><text class="terminal-r1" x="341.6" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">, </text><text class="terminal-r2" x="366" y="654.4" textLength="48.8" clip-path="url(#terminal-line-26)">quit</text><text class="terminal-r1" x="414.8" y="654.4" textLength="268.4" clip-path="url(#terminal-line-26)">: Exit the application</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> -</text><text class="terminal-r1" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">⎢</text><text class="terminal-r1" x="48.8" y="678.8" textLength="24.4" clip-path="url(#terminal-line-27)">• </text><text class="terminal-r2" x="73.2" y="678.8" textLength="85.4" clip-path="url(#terminal-line-27)">/status</text><text class="terminal-r1" x="158.6" y="678.8" textLength="317.2" clip-path="url(#terminal-line-27)">: Display agent statistics</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> -</text><text class="terminal-r1" x="24.4" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">⎢</text><text class="terminal-r1" x="48.8" y="703.2" textLength="24.4" clip-path="url(#terminal-line-28)">• </text><text class="terminal-r2" x="73.2" y="703.2" textLength="109.8" clip-path="url(#terminal-line-28)">/teleport</text><text class="terminal-r1" x="183" y="703.2" textLength="427" clip-path="url(#terminal-line-28)">: Teleport session to Vibe Code Web</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> -</text><text class="terminal-r1" x="24.4" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">⎢</text><text class="terminal-r1" x="48.8" y="727.6" textLength="24.4" clip-path="url(#terminal-line-29)">• </text><text class="terminal-r2" x="73.2" y="727.6" textLength="146.4" clip-path="url(#terminal-line-29)">/proxy-setup</text><text class="terminal-r1" x="219.6" y="727.6" textLength="561.2" clip-path="url(#terminal-line-29)">: Configure proxy and SSL certificate settings</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"> -</text><text class="terminal-r1" x="24.4" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">⎢</text><text class="terminal-r1" x="48.8" y="752" textLength="24.4" clip-path="url(#terminal-line-30)">• </text><text class="terminal-r2" x="73.2" y="752" textLength="109.8" clip-path="url(#terminal-line-30)">/continue</text><text class="terminal-r1" x="183" y="752" textLength="24.4" clip-path="url(#terminal-line-30)">, </text><text class="terminal-r2" x="207.4" y="752" textLength="85.4" clip-path="url(#terminal-line-30)">/resume</text><text class="terminal-r1" x="292.8" y="752" textLength="512.4" clip-path="url(#terminal-line-30)">: Browse, resume, or delete saved sessions</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"> -</text><text class="terminal-r1" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">⎢</text><text class="terminal-r1" x="48.8" y="776.4" textLength="24.4" clip-path="url(#terminal-line-31)">• </text><text class="terminal-r2" x="73.2" y="776.4" textLength="85.4" clip-path="url(#terminal-line-31)">/rename</text><text class="terminal-r1" x="158.6" y="776.4" textLength="341.6" clip-path="url(#terminal-line-31)">: Rename the current session</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"> -</text><text class="terminal-r1" x="24.4" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">⎢</text><text class="terminal-r1" x="48.8" y="800.8" textLength="24.4" clip-path="url(#terminal-line-32)">• </text><text class="terminal-r2" x="73.2" y="800.8" textLength="134.2" clip-path="url(#terminal-line-32)">/connectors</text><text class="terminal-r1" x="207.4" y="800.8" textLength="24.4" clip-path="url(#terminal-line-32)">, </text><text class="terminal-r2" x="231.8" y="800.8" textLength="48.8" clip-path="url(#terminal-line-32)">/mcp</text><text class="terminal-r1" x="280.6" y="800.8" textLength="939.4" clip-path="url(#terminal-line-32)">: Display available MCP servers and connectors. Pass a name to list its tools</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"> + <text class="terminal-r1" x="24.4" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">⎢</text><text class="terminal-r1" x="48.8" y="20" textLength="24.4" clip-path="url(#terminal-line-0)">• </text><text class="terminal-r2" x="73.2" y="20" textLength="73.2" clip-path="url(#terminal-line-0)">Ctrl+J</text><text class="terminal-r1" x="146.4" y="20" textLength="36.6" clip-path="url(#terminal-line-0)"> / </text><text class="terminal-r2" x="183" y="20" textLength="134.2" clip-path="url(#terminal-line-0)">Shift+Enter</text><text class="terminal-r1" x="317.2" y="20" textLength="183" clip-path="url(#terminal-line-0)"> Insert newline</text><text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"> +</text><text class="terminal-r1" x="24.4" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">⎢</text><text class="terminal-r1" x="48.8" y="44.4" textLength="24.4" clip-path="url(#terminal-line-1)">• </text><text class="terminal-r2" x="73.2" y="44.4" textLength="73.2" clip-path="url(#terminal-line-1)">Escape</text><text class="terminal-r1" x="146.4" y="44.4" textLength="402.6" clip-path="url(#terminal-line-1)"> Interrupt agent or close dialogs</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"> +</text><text class="terminal-r1" x="24.4" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">⎢</text><text class="terminal-r1" x="48.8" y="68.8" textLength="24.4" clip-path="url(#terminal-line-2)">• </text><text class="terminal-r2" x="73.2" y="68.8" textLength="73.2" clip-path="url(#terminal-line-2)">Ctrl+C</text><text class="terminal-r1" x="146.4" y="68.8" textLength="463.6" clip-path="url(#terminal-line-2)"> Quit (or clear input if text present)</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"> +</text><text class="terminal-r1" x="24.4" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">⎢</text><text class="terminal-r1" x="48.8" y="93.2" textLength="24.4" clip-path="url(#terminal-line-3)">• </text><text class="terminal-r2" x="73.2" y="93.2" textLength="73.2" clip-path="url(#terminal-line-3)">Ctrl+G</text><text class="terminal-r1" x="146.4" y="93.2" textLength="366" clip-path="url(#terminal-line-3)"> Edit input in external editor</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"> +</text><text class="terminal-r1" x="24.4" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">⎢</text><text class="terminal-r1" x="48.8" y="117.6" textLength="24.4" clip-path="url(#terminal-line-4)">• </text><text class="terminal-r2" x="73.2" y="117.6" textLength="73.2" clip-path="url(#terminal-line-4)">Ctrl+O</text><text class="terminal-r1" x="146.4" y="117.6" textLength="292.8" clip-path="url(#terminal-line-4)"> Toggle tool output view</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"> +</text><text class="terminal-r1" x="24.4" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">⎢</text><text class="terminal-r1" x="48.8" y="142" textLength="24.4" clip-path="url(#terminal-line-5)">• </text><text class="terminal-r2" x="73.2" y="142" textLength="109.8" clip-path="url(#terminal-line-5)">Shift+Tab</text><text class="terminal-r1" x="183" y="142" textLength="512.4" clip-path="url(#terminal-line-5)"> Cycle through agents (default, plan, ...)</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"> +</text><text class="terminal-r1" x="24.4" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">⎢</text><text class="terminal-r1" x="48.8" y="166.4" textLength="24.4" clip-path="url(#terminal-line-6)">• </text><text class="terminal-r2" x="73.2" y="166.4" textLength="73.2" clip-path="url(#terminal-line-6)">Alt+↑↓</text><text class="terminal-r1" x="146.4" y="166.4" textLength="36.6" clip-path="url(#terminal-line-6)"> / </text><text class="terminal-r2" x="183" y="166.4" textLength="97.6" clip-path="url(#terminal-line-6)">Ctrl+P/N</text><text class="terminal-r1" x="280.6" y="166.4" textLength="390.4" clip-path="url(#terminal-line-6)"> Rewind to previous/next message</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"> +</text><text class="terminal-r1" x="24.4" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">⎢</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"> +</text><text class="terminal-r1" x="24.4" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">⎢</text><text class="terminal-r3" x="48.8" y="215.2" textLength="195.2" clip-path="url(#terminal-line-8)">Special Features</text><text class="terminal-r3" x="1451.8" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">▃</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"> +</text><text class="terminal-r1" x="24.4" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">⎢</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"> +</text><text class="terminal-r1" x="24.4" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">⎢</text><text class="terminal-r1" x="48.8" y="264" textLength="24.4" clip-path="url(#terminal-line-10)">• </text><text class="terminal-r2" x="73.2" y="264" textLength="122" clip-path="url(#terminal-line-10)">!<command></text><text class="terminal-r1" x="195.2" y="264" textLength="366" clip-path="url(#terminal-line-10)"> Execute bash command directly</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"> +</text><text class="terminal-r1" x="24.4" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">⎢</text><text class="terminal-r1" x="48.8" y="288.4" textLength="24.4" clip-path="url(#terminal-line-11)">• </text><text class="terminal-r2" x="73.2" y="288.4" textLength="170.8" clip-path="url(#terminal-line-11)">@path/to/file/</text><text class="terminal-r1" x="244" y="288.4" textLength="305" clip-path="url(#terminal-line-11)"> Autocompletes file paths</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"> +</text><text class="terminal-r1" x="24.4" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">⎢</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"> +</text><text class="terminal-r1" x="24.4" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">⎢</text><text class="terminal-r3" x="48.8" y="337.2" textLength="97.6" clip-path="url(#terminal-line-13)">Commands</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)"> +</text><text class="terminal-r1" x="24.4" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">⎢</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)"> +</text><text class="terminal-r1" x="24.4" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">⎢</text><text class="terminal-r1" x="48.8" y="386" textLength="24.4" clip-path="url(#terminal-line-15)">• </text><text class="terminal-r2" x="73.2" y="386" textLength="61" clip-path="url(#terminal-line-15)">/help</text><text class="terminal-r1" x="134.2" y="386" textLength="231.8" clip-path="url(#terminal-line-15)">: Show help message</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)"> +</text><text class="terminal-r1" x="24.4" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">⎢</text><text class="terminal-r1" x="48.8" y="410.4" textLength="24.4" clip-path="url(#terminal-line-16)">• </text><text class="terminal-r2" x="73.2" y="410.4" textLength="85.4" clip-path="url(#terminal-line-16)">/config</text><text class="terminal-r1" x="158.6" y="410.4" textLength="268.4" clip-path="url(#terminal-line-16)">: Edit config settings</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)"> +</text><text class="terminal-r1" x="24.4" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">⎢</text><text class="terminal-r1" x="48.8" y="434.8" textLength="24.4" clip-path="url(#terminal-line-17)">• </text><text class="terminal-r2" x="73.2" y="434.8" textLength="73.2" clip-path="url(#terminal-line-17)">/model</text><text class="terminal-r1" x="146.4" y="434.8" textLength="256.2" clip-path="url(#terminal-line-17)">: Select active model</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)"> +</text><text class="terminal-r1" x="24.4" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">⎢</text><text class="terminal-r1" x="48.8" y="459.2" textLength="24.4" clip-path="url(#terminal-line-18)">• </text><text class="terminal-r2" x="73.2" y="459.2" textLength="109.8" clip-path="url(#terminal-line-18)">/thinking</text><text class="terminal-r1" x="183" y="459.2" textLength="280.6" clip-path="url(#terminal-line-18)">: Select thinking level</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)"> +</text><text class="terminal-r1" x="24.4" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">⎢</text><text class="terminal-r1" x="48.8" y="483.6" textLength="24.4" clip-path="url(#terminal-line-19)">• </text><text class="terminal-r2" x="73.2" y="483.6" textLength="85.4" clip-path="url(#terminal-line-19)">/reload</text><text class="terminal-r1" x="158.6" y="483.6" textLength="780.8" clip-path="url(#terminal-line-19)">: Reload configuration, agent instructions, and skills from disk</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)"> +</text><text class="terminal-r1" x="24.4" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">⎢</text><text class="terminal-r1" x="48.8" y="508" textLength="24.4" clip-path="url(#terminal-line-20)">• </text><text class="terminal-r2" x="73.2" y="508" textLength="73.2" clip-path="url(#terminal-line-20)">/clear</text><text class="terminal-r1" x="146.4" y="508" textLength="341.6" clip-path="url(#terminal-line-20)">: Clear conversation history</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)"> +</text><text class="terminal-r1" x="24.4" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">⎢</text><text class="terminal-r1" x="48.8" y="532.4" textLength="24.4" clip-path="url(#terminal-line-21)">• </text><text class="terminal-r2" x="73.2" y="532.4" textLength="61" clip-path="url(#terminal-line-21)">/copy</text><text class="terminal-r1" x="134.2" y="532.4" textLength="561.2" clip-path="url(#terminal-line-21)">: Copy the last agent message to the clipboard</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)"> +</text><text class="terminal-r1" x="24.4" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">⎢</text><text class="terminal-r1" x="48.8" y="556.8" textLength="24.4" clip-path="url(#terminal-line-22)">• </text><text class="terminal-r2" x="73.2" y="556.8" textLength="48.8" clip-path="url(#terminal-line-22)">/log</text><text class="terminal-r1" x="122" y="556.8" textLength="524.6" clip-path="url(#terminal-line-22)">: Show path to current interaction log file</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"> +</text><text class="terminal-r1" x="24.4" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">⎢</text><text class="terminal-r1" x="48.8" y="581.2" textLength="24.4" clip-path="url(#terminal-line-23)">• </text><text class="terminal-r2" x="73.2" y="581.2" textLength="73.2" clip-path="url(#terminal-line-23)">/debug</text><text class="terminal-r1" x="146.4" y="581.2" textLength="268.4" clip-path="url(#terminal-line-23)">: Toggle debug console</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"> +</text><text class="terminal-r1" x="24.4" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">⎢</text><text class="terminal-r1" x="48.8" y="605.6" textLength="24.4" clip-path="url(#terminal-line-24)">• </text><text class="terminal-r2" x="73.2" y="605.6" textLength="97.6" clip-path="url(#terminal-line-24)">/compact</text><text class="terminal-r1" x="170.8" y="605.6" textLength="1171.2" clip-path="url(#terminal-line-24)">: Compact conversation history by summarizing. Optionally pass instructions to guide the summary</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"> +</text><text class="terminal-r1" x="24.4" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">⎢</text><text class="terminal-r1" x="48.8" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">• </text><text class="terminal-r2" x="73.2" y="630" textLength="61" clip-path="url(#terminal-line-25)">/exit</text><text class="terminal-r1" x="134.2" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">, </text><text class="terminal-r2" x="158.6" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">:q</text><text class="terminal-r1" x="183" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">, </text><text class="terminal-r2" x="207.4" y="630" textLength="61" clip-path="url(#terminal-line-25)">:quit</text><text class="terminal-r1" x="268.4" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">, </text><text class="terminal-r2" x="292.8" y="630" textLength="48.8" clip-path="url(#terminal-line-25)">exit</text><text class="terminal-r1" x="341.6" y="630" textLength="24.4" clip-path="url(#terminal-line-25)">, </text><text class="terminal-r2" x="366" y="630" textLength="48.8" clip-path="url(#terminal-line-25)">quit</text><text class="terminal-r1" x="414.8" y="630" textLength="268.4" clip-path="url(#terminal-line-25)">: Exit the application</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> +</text><text class="terminal-r1" x="24.4" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">⎢</text><text class="terminal-r1" x="48.8" y="654.4" textLength="24.4" clip-path="url(#terminal-line-26)">• </text><text class="terminal-r2" x="73.2" y="654.4" textLength="85.4" clip-path="url(#terminal-line-26)">/status</text><text class="terminal-r1" x="158.6" y="654.4" textLength="317.2" clip-path="url(#terminal-line-26)">: Display agent statistics</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> +</text><text class="terminal-r1" x="24.4" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">⎢</text><text class="terminal-r1" x="48.8" y="678.8" textLength="24.4" clip-path="url(#terminal-line-27)">• </text><text class="terminal-r2" x="73.2" y="678.8" textLength="109.8" clip-path="url(#terminal-line-27)">/teleport</text><text class="terminal-r1" x="183" y="678.8" textLength="427" clip-path="url(#terminal-line-27)">: Teleport session to Vibe Code Web</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> +</text><text class="terminal-r1" x="24.4" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">⎢</text><text class="terminal-r1" x="48.8" y="703.2" textLength="24.4" clip-path="url(#terminal-line-28)">• </text><text class="terminal-r2" x="73.2" y="703.2" textLength="146.4" clip-path="url(#terminal-line-28)">/proxy-setup</text><text class="terminal-r1" x="219.6" y="703.2" textLength="561.2" clip-path="url(#terminal-line-28)">: Configure proxy and SSL certificate settings</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> +</text><text class="terminal-r1" x="24.4" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">⎢</text><text class="terminal-r1" x="48.8" y="727.6" textLength="24.4" clip-path="url(#terminal-line-29)">• </text><text class="terminal-r2" x="73.2" y="727.6" textLength="109.8" clip-path="url(#terminal-line-29)">/continue</text><text class="terminal-r1" x="183" y="727.6" textLength="24.4" clip-path="url(#terminal-line-29)">, </text><text class="terminal-r2" x="207.4" y="727.6" textLength="85.4" clip-path="url(#terminal-line-29)">/resume</text><text class="terminal-r1" x="292.8" y="727.6" textLength="512.4" clip-path="url(#terminal-line-29)">: Browse, resume, or delete saved sessions</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"> +</text><text class="terminal-r1" x="24.4" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">⎢</text><text class="terminal-r1" x="48.8" y="752" textLength="24.4" clip-path="url(#terminal-line-30)">• </text><text class="terminal-r2" x="73.2" y="752" textLength="85.4" clip-path="url(#terminal-line-30)">/rename</text><text class="terminal-r1" x="158.6" y="752" textLength="341.6" clip-path="url(#terminal-line-30)">: Rename the current session</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)"> +</text><text class="terminal-r1" x="24.4" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">⎢</text><text class="terminal-r1" x="48.8" y="776.4" textLength="24.4" clip-path="url(#terminal-line-31)">• </text><text class="terminal-r2" x="73.2" y="776.4" textLength="134.2" clip-path="url(#terminal-line-31)">/connectors</text><text class="terminal-r1" x="207.4" y="776.4" textLength="24.4" clip-path="url(#terminal-line-31)">, </text><text class="terminal-r2" x="231.8" y="776.4" textLength="48.8" clip-path="url(#terminal-line-31)">/mcp</text><text class="terminal-r1" x="280.6" y="776.4" textLength="1061.4" clip-path="url(#terminal-line-31)">: Display available MCP servers and connectors. Pass a name to list tools; subcommands:</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)"> +</text><text class="terminal-r1" x="24.4" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">⎢</text><text class="terminal-r1" x="73.2" y="800.8" textLength="280.6" clip-path="url(#terminal-line-32)">status, login , logout </text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)"> </text><text class="terminal-r1" x="24.4" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">⎢</text><text class="terminal-r1" x="48.8" y="825.2" textLength="24.4" clip-path="url(#terminal-line-33)">• </text><text class="terminal-r2" x="73.2" y="825.2" textLength="73.2" clip-path="url(#terminal-line-33)">/voice</text><text class="terminal-r1" x="146.4" y="825.2" textLength="317.2" clip-path="url(#terminal-line-33)">: Configure voice settings</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)"> </text><text class="terminal-r1" x="24.4" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">⎢</text><text class="terminal-r1" x="48.8" y="849.6" textLength="24.4" clip-path="url(#terminal-line-34)">• </text><text class="terminal-r2" x="73.2" y="849.6" textLength="122" clip-path="url(#terminal-line-34)">/leanstall</text><text class="terminal-r1" x="195.2" y="849.6" textLength="463.6" clip-path="url(#terminal-line-34)">: Install the Lean 4 agent (leanstral)</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)"> </text><text class="terminal-r1" x="24.4" y="874" textLength="12.2" clip-path="url(#terminal-line-35)">⎢</text><text class="terminal-r1" x="48.8" y="874" textLength="24.4" clip-path="url(#terminal-line-35)">• </text><text class="terminal-r2" x="73.2" y="874" textLength="146.4" clip-path="url(#terminal-line-35)">/unleanstall</text><text class="terminal-r1" x="219.6" y="874" textLength="341.6" clip-path="url(#terminal-line-35)">: Uninstall the Lean 4 agent</text><text class="terminal-r1" x="1464" y="874" textLength="12.2" clip-path="url(#terminal-line-35)"> diff --git a/tests/snapshots/test_ui_snapshot_edit_diff.py b/tests/snapshots/test_ui_snapshot_edit_diff.py index 17e282e..a54fb4a 100644 --- a/tests/snapshots/test_ui_snapshot_edit_diff.py +++ b/tests/snapshots/test_ui_snapshot_edit_diff.py @@ -38,6 +38,37 @@ class EditApprovalAnsiApp(EditApprovalApp): _diff_theme = "ansi-dark" +REPLACE_ALL_CONTENT = "\n".join([ + "def total(items):", + " count = 0", + " for item in items:", + " count = count + 1", + " return count", + "", + "def reset():", + " count = 0", + " return count", +]) + + +class EditReplaceAllApprovalApp(BaseSnapshotTestApp): + _diff_theme: str = "tokyo-night" + + async def on_ready(self) -> None: + await super().on_ready() + self.theme = self._diff_theme + path = Path("src/counter.py") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(REPLACE_ALL_CONTENT) + args = EditArgs( + file_path="src/counter.py", + old_string="count = 0", + new_string="count = 1", + replace_all=True, + ) + await self._switch_to_approval_app("edit", args) + + def test_snapshot_edit_approval_diff(snap_compare: SnapCompare) -> None: async def run_before(pilot: Pilot) -> None: await pilot.pause(0.3) @@ -58,3 +89,14 @@ def test_snapshot_edit_approval_diff_ansi(snap_compare: SnapCompare) -> None: terminal_size=(100, 30), run_before=run_before, ) + + +def test_snapshot_edit_approval_diff_replace_all(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.3) + + assert snap_compare( + "test_ui_snapshot_edit_diff.py:EditReplaceAllApprovalApp", + terminal_size=(100, 30), + run_before=run_before, + ) diff --git a/tests/snapshots/test_ui_snapshot_right_padding.py b/tests/snapshots/test_ui_snapshot_right_padding.py new file mode 100644 index 0000000..1b6f65f --- /dev/null +++ b/tests/snapshots/test_ui_snapshot_right_padding.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from textual.pilot import Pilot + +from tests.conftest import build_test_agent_loop +from tests.mock.utils import mock_llm_chunk +from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config +from tests.snapshots.snap_compare import SnapCompare +from tests.stubs.fake_backend import FakeBackend +from vibe.cli.textual_ui.widgets.messages import ReasoningMessage + +LONG_LINE = ( + "The history of computing stretches back thousands of years from the abacus" + " through Charles Babbage's Analytical Engine to Alan Turing's theoretical" + " foundations and the first electronic computers like ENIAC and UNIVAC which" + " filled entire rooms and consumed enormous amounts of power while performing" + " calculations that today's pocket calculators handle effortlessly and this" + " remarkable progression continued through the invention of the transistor" + " and integrated circuit leading to the personal computer revolution of the" + " 1980s and the explosive growth of the internet in the 1990s transforming" + " every aspect of modern life from communication to commerce to entertainment." +) + + +class AssistantLongLineApp(BaseSnapshotTestApp): + def __init__(self) -> None: + fake_backend = FakeBackend( + mock_llm_chunk( + content=LONG_LINE, prompt_tokens=10_000, completion_tokens=5_000 + ) + ) + super().__init__(backend=fake_backend) + + +def test_snapshot_right_padding_assistant_message(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.press(*"Tell me about computing") + await pilot.press("enter") + await pilot.pause(0.4) + + assert snap_compare( + "test_ui_snapshot_right_padding.py:AssistantLongLineApp", + terminal_size=(120, 36), + run_before=run_before, + ) + + +class ReasoningLongLineApp(BaseSnapshotTestApp): + def __init__(self) -> None: + config = default_config() + fake_backend = FakeBackend( + chunks=[ + mock_llm_chunk(content="", reasoning_content=LONG_LINE), + mock_llm_chunk(content="The answer is 42."), + ] + ) + super().__init__(config=config) + self.agent_loop = build_test_agent_loop( + config=config, + agent_name=self._current_agent_name, + enable_streaming=True, + backend=fake_backend, + ) + + +def test_snapshot_right_padding_reasoning(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.press(*"Think hard") + await pilot.press("enter") + await pilot.pause(0.5) + reasoning_msg = pilot.app.query_one(ReasoningMessage) + await pilot.click(reasoning_msg) + await pilot.pause(0.1) + + assert snap_compare( + "test_ui_snapshot_right_padding.py:ReasoningLongLineApp", + terminal_size=(120, 36), + run_before=run_before, + ) diff --git a/tests/snapshots/test_ui_snapshot_session_picker.py b/tests/snapshots/test_ui_snapshot_session_picker.py index 615162b..038e13f 100644 --- a/tests/snapshots/test_ui_snapshot_session_picker.py +++ b/tests/snapshots/test_ui_snapshot_session_picker.py @@ -10,24 +10,21 @@ from vibe.core.session.resume_sessions import ResumeSessionInfo _SESSIONS = [ ResumeSessionInfo( session_id="local-session-0001", - source="local", cwd="/test/workdir", title="Refactor the auth module", end_time=None, ), ResumeSessionInfo( - session_id="remote-session-0002", - source="remote", - cwd="", - title="Vibe Code", + session_id="local-session-0002", + cwd="/test/workdir", + title="Add unit tests for the API", end_time=None, - status="RUNNING", ), ] _LATEST_MESSAGES = { _SESSIONS[0].option_id: "Refactor the auth module", - _SESSIONS[1].option_id: "Vibe Code (running)", + _SESSIONS[1].option_id: "Add unit tests for the API", } diff --git a/tests/stubs/fake_mcp_registry.py b/tests/stubs/fake_mcp_registry.py index 5419308..a4b78fb 100644 --- a/tests/stubs/fake_mcp_registry.py +++ b/tests/stubs/fake_mcp_registry.py @@ -26,12 +26,14 @@ class FakeMCPRegistry(MCPRegistry): def set_tools( self, servers: list[MCPServer], tools: dict[str, type[BaseTool]] ) -> None: + self.sync_active_servers(servers) for srv in servers: key = self._server_key(srv) self._cache[key] = dict(tools) def get_tools(self, servers: list[MCPServer]) -> dict[str, type[BaseTool]]: result: dict[str, type[BaseTool]] = {} + self.sync_active_servers(servers) for srv in servers: key = self._server_key(srv) if key not in self._cache: diff --git a/tests/test_mcp_auth_notices.py b/tests/test_mcp_auth_notices.py new file mode 100644 index 0000000..a973763 --- /dev/null +++ b/tests/test_mcp_auth_notices.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import cast +from unittest.mock import AsyncMock + +from acp.schema import AgentMessageChunk +import pytest + +from tests.stubs.fake_client import FakeClient +from vibe.acp.acp_agent_loop import VibeAcpAgentLoop +from vibe.acp.session import AcpSessionLoop +from vibe.cli.textual_ui.app import VibeApp +from vibe.cli.textual_ui.widgets.messages import UserCommandMessage +from vibe.core.config import MCPOAuth, MCPStreamableHttp +from vibe.core.tools.mcp import MCPRegistry + + +def _registry_with_uncached_oauth(alias: str) -> MCPRegistry: + registry = MCPRegistry() + registry.sync_active_servers([ + MCPStreamableHttp( + name=alias, + transport="streamable-http", + url="https://mcp.example.com/mcp", + auth=MCPOAuth(type="oauth", scopes=["read"]), + ) + ]) + assert registry.needs_auth == set() + return registry + + +@pytest.mark.asyncio +async def test_tui_mcp_auth_notice_uses_status_for_uncached_oauth() -> None: + mount = AsyncMock() + app = cast( + VibeApp, + SimpleNamespace( + agent_loop=SimpleNamespace( + mcp_registry=_registry_with_uncached_oauth("sentry") + ), + _mount_and_scroll=mount, + ), + ) + + await VibeApp._show_mcp_auth_required_notice(app) + + mount.assert_awaited_once() + args = mount.await_args + assert args is not None + message = args.args[0] + assert isinstance(message, UserCommandMessage) + assert "sentry" in message._content + + +@pytest.mark.asyncio +async def test_acp_mcp_auth_notice_uses_status_for_uncached_oauth() -> None: + agent = VibeAcpAgentLoop() + client = FakeClient() + agent.on_connect(client) + session = cast( + AcpSessionLoop, + SimpleNamespace( + id="session-id", + agent_loop=SimpleNamespace( + mcp_registry=_registry_with_uncached_oauth("sentry") + ), + ), + ) + + await agent._notify_mcp_auth_required(session) + + messages = [ + notification.update + for notification in client._session_updates + if isinstance(notification.update, AgentMessageChunk) + ] + assert len(messages) == 1 + assert "sentry" in messages[0].content.text diff --git a/tests/test_reasoning_content.py b/tests/test_reasoning_content.py index bcaac5b..95fdad2 100644 --- a/tests/test_reasoning_content.py +++ b/tests/test_reasoning_content.py @@ -14,13 +14,20 @@ import pytest import respx from tests.conftest import build_test_agent_loop, build_test_vibe_config +from tests.constants import CHAT_COMPLETIONS_PATH from tests.mock.utils import mock_llm_chunk from tests.stubs.fake_backend import FakeBackend from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig from vibe.core.llm.backend.generic import GenericBackend, OpenAIAdapter from vibe.core.llm.backend.mistral import MistralBackend, MistralMapper, ParsedContent from vibe.core.llm.format import APIToolFormatHandler -from vibe.core.types import AssistantEvent, LLMMessage, ReasoningEvent, Role +from vibe.core.types import ( + AssistantEvent, + LLMMessage, + ReasoningEvent, + Role, + UserDisplayContentMetadata, +) def make_config() -> VibeConfig: @@ -194,7 +201,7 @@ class TestGenericBackendReasoningContent: } with respx.mock(base_url=base_url) as mock_api: - mock_api.post("/v1/chat/completions").mock( + mock_api.post(CHAT_COMPLETIONS_PATH).mock( return_value=httpx.Response(status_code=200, json=json_response) ) provider = ProviderConfig( @@ -228,7 +235,7 @@ class TestGenericBackendReasoningContent: ] with respx.mock(base_url=base_url) as mock_api: - mock_api.post("/v1/chat/completions").mock( + mock_api.post(CHAT_COMPLETIONS_PATH).mock( return_value=httpx.Response( status_code=200, stream=httpx.ByteStream(stream=b"\n\n".join(chunks)), @@ -455,6 +462,49 @@ class TestReasoningFieldNameConversion: assert payload["messages"][0]["reasoning_content"] == "Thinking..." assert "reasoning_state" not in payload["messages"][0] + def test_prepare_request_excludes_user_display_content_from_completions_payload( + self, + ): + adapter = OpenAIAdapter() + provider = ProviderConfig( + name="test", + api_base="https://api.example.com/v1", + api_key_env_var="API_KEY", + ) + + request = adapter.prepare_request( + model_name="test-model", + messages=[ + LLMMessage( + role=Role.user, + content="Look at app.ts", + user_display_content=UserDisplayContentMetadata( + version="1.0.0", + host="mistral-vscode", + content=[ + {"type": "text", "text": "Look at "}, + { + "type": "workspace_mention", + "kind": "file", + "uri": "file:///repo/src/app.ts", + "name": "app.ts", + }, + ], + ), + ) + ], + temperature=0.2, + tools=None, + max_tokens=None, + tool_choice=None, + enable_streaming=False, + provider=provider, + ) + + payload = json.loads(request.body) + + assert payload["messages"][0] == {"role": "user", "content": "Look at app.ts"} + @pytest.mark.asyncio async def test_complete_with_custom_reasoning_field_name(self): base_url = "https://api.example.com" @@ -478,7 +528,7 @@ class TestReasoningFieldNameConversion: } with respx.mock(base_url=base_url) as mock_api: - mock_api.post("/v1/chat/completions").mock( + mock_api.post(CHAT_COMPLETIONS_PATH).mock( return_value=httpx.Response(status_code=200, json=json_response) ) provider = ProviderConfig( @@ -515,7 +565,7 @@ class TestReasoningFieldNameConversion: ] with respx.mock(base_url=base_url) as mock_api: - mock_api.post("/v1/chat/completions").mock( + mock_api.post(CHAT_COMPLETIONS_PATH).mock( return_value=httpx.Response( status_code=200, stream=httpx.ByteStream(stream=b"\n\n".join(chunks)), diff --git a/tests/tools/test_connectors.py b/tests/tools/test_connectors.py index 408aa6e..af45487 100644 --- a/tests/tools/test_connectors.py +++ b/tests/tools/test_connectors.py @@ -8,6 +8,7 @@ import pytest import respx from tests.conftest import build_test_vibe_config +from tests.constants import CONNECTORS_BOOTSTRAP_PATH, MISTRAL_BASE_URL from tests.stubs.fake_connector_registry import FakeConnectorRegistry from tests.stubs.fake_mcp_registry import FakeMCPRegistry from vibe.core.config import ConnectorConfig, VibeConfig @@ -468,7 +469,7 @@ class TestConnectorDisableFiltering: # Bootstrap-based discovery (ConnectorRegistry._discover_all via httpx) # --------------------------------------------------------------------------- -_BOOTSTRAP_URL = "https://api.mistral.ai/v1/connectors/bootstrap" +_BOOTSTRAP_URL = f"{MISTRAL_BASE_URL}{CONNECTORS_BOOTSTRAP_PATH}" def _make_bootstrap_response( diff --git a/tests/tools/test_mcp.py b/tests/tools/test_mcp.py index 1d302a0..28bb961 100644 --- a/tests/tools/test_mcp.py +++ b/tests/tools/test_mcp.py @@ -1,21 +1,28 @@ from __future__ import annotations +import asyncio import contextlib import logging import os +import re +import sys import threading import time from types import SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch +import anyio from pydantic import ValidationError import pytest from tests.conftest import build_test_vibe_config from tests.stubs.fake_mcp_registry import FakeMCPRegistry from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp, VibeConfig +from vibe.core.tools.base import BaseToolConfig, BaseToolState, InvokeContext from vibe.core.tools.mcp import ( + AuthStatus, + MCPConnectionPool, MCPRegistry, MCPToolResult, RemoteTool, @@ -30,6 +37,8 @@ from vibe.core.tools.mcp import ( list_tools_http, list_tools_stdio, ) +from vibe.core.tools.mcp.pool import _StdioConnection, stdio_key +from vibe.core.tools.mcp.tools import _OpenArgs, build_stdio_params class TestRemoteTool: @@ -555,6 +564,28 @@ class TestMCPRegistry: assert registry.get_tools([]) == {} + def test_get_tools_reconciles_status_with_active_servers(self): + registry = MCPRegistry() + kept = self._make_http_server("kept", url="http://kept:1") + removed = self._make_http_server("removed", url="http://removed:1") + kept_proxy = create_mcp_http_proxy_tool_class( + url="http://kept:1", remote=RemoteTool(name="search"), alias="kept" + ) + removed_proxy = create_mcp_http_proxy_tool_class( + url="http://removed:1", remote=RemoteTool(name="search"), alias="removed" + ) + registry._cache[registry._server_key(kept)] = { + kept_proxy.get_name(): kept_proxy + } + registry._cache[registry._server_key(removed)] = { + removed_proxy.get_name(): removed_proxy + } + + registry.get_tools([kept, removed]) + registry.get_tools([kept]) + + assert registry.status() == {"kept": AuthStatus.STATIC} + def test_clear_drops_cache(self): registry = MCPRegistry() srv = self._make_http_server("s") @@ -968,3 +999,325 @@ class TestMCPDisableFiltering: config_getter=lambda: config, mcp_registry=registry, connector_registry=None ) assert "demo_tool_a" in tm.available_tools + + +def _ok_result(value: dict[str, Any] | None = None) -> SimpleNamespace: + return SimpleNamespace(structuredContent=value or {"ok": 1}, content=None) + + +class _FakeSession: + def __init__(self, call_tool: AsyncMock) -> None: + self.call_tool = call_tool + + +def _patch_enter(sessions: list[_FakeSession], closed: list[_FakeSession]) -> AsyncMock: + it = iter(sessions) + + async def _record_close(session: _FakeSession) -> None: + closed.append(session) + + async def _enter(stack, params, *, init_timeout, sampling_callback=None): + session = next(it) + stack.push_async_callback(_record_close, session) + return session + + return AsyncMock(side_effect=_enter) + + +class TestMCPConnectionPool: + @pytest.mark.asyncio + async def test_reuses_connection_across_calls(self): + call_tool = AsyncMock(return_value=_ok_result()) + session = _FakeSession(call_tool) + enter = _patch_enter([session], []) + pool = MCPConnectionPool() + + with patch("vibe.core.tools.mcp.pool.enter_stdio_session", enter): + r1 = await pool.call_tool( + command=["srv"], tool_name="t", arguments={"a": 1} + ) + r2 = await pool.call_tool( + command=["srv"], tool_name="t", arguments={"a": 2} + ) + + assert enter.call_count == 1 + assert call_tool.await_count == 2 + assert isinstance(r1, MCPToolResult) and r1.structured == {"ok": 1} + assert r2.structured == {"ok": 1} + + @pytest.mark.asyncio + async def test_serializes_concurrent_calls_to_same_connection(self): + active = 0 + max_active = 0 + order: list[int] = [] + + async def _call(tool_name, arguments, read_timeout_seconds=None): + nonlocal active, max_active + active += 1 + max_active = max(max_active, active) + await asyncio.sleep(0.01) + order.append(arguments["i"]) + active -= 1 + return _ok_result() + + session = _FakeSession(AsyncMock(side_effect=_call)) + enter = _patch_enter([session], []) + pool = MCPConnectionPool() + + with patch("vibe.core.tools.mcp.pool.enter_stdio_session", enter): + await asyncio.gather( + *( + pool.call_tool(command=["srv"], tool_name="t", arguments={"i": i}) + for i in range(5) + ) + ) + + assert enter.call_count == 1 + assert max_active == 1 + assert order == [0, 1, 2, 3, 4] + + @pytest.mark.asyncio + async def test_reconnects_once_on_transport_death(self): + dead = AsyncMock(side_effect=anyio.ClosedResourceError()) + alive = AsyncMock(return_value=_ok_result({"recovered": 1})) + sessions = [_FakeSession(dead), _FakeSession(alive)] + enter = _patch_enter(sessions, []) + pool = MCPConnectionPool() + + with patch("vibe.core.tools.mcp.pool.enter_stdio_session", enter): + result = await pool.call_tool(command=["srv"], tool_name="t", arguments={}) + + assert enter.call_count == 2 + assert result.structured == {"recovered": 1} + + @pytest.mark.asyncio + async def test_second_transport_failure_propagates(self): + dead1 = AsyncMock(side_effect=anyio.ClosedResourceError()) + dead2 = AsyncMock(side_effect=anyio.BrokenResourceError()) + sessions = [_FakeSession(dead1), _FakeSession(dead2)] + enter = _patch_enter(sessions, []) + pool = MCPConnectionPool() + + with patch("vibe.core.tools.mcp.pool.enter_stdio_session", enter): + with pytest.raises(anyio.BrokenResourceError): + await pool.call_tool(command=["srv"], tool_name="t", arguments={}) + + assert enter.call_count == 2 + + @pytest.mark.asyncio + async def test_distinct_keys_get_distinct_connections(self): + sessions = [ + _FakeSession(AsyncMock(return_value=_ok_result())), + _FakeSession(AsyncMock(return_value=_ok_result())), + ] + enter = _patch_enter(sessions, []) + pool = MCPConnectionPool() + + with patch("vibe.core.tools.mcp.pool.enter_stdio_session", enter): + await pool.call_tool(command=["srv-a"], tool_name="t", arguments={}) + await pool.call_tool(command=["srv-b"], tool_name="t", arguments={}) + + assert enter.call_count == 2 + + @pytest.mark.asyncio + async def test_same_key_different_env_get_distinct_connections(self): + sessions = [ + _FakeSession(AsyncMock(return_value=_ok_result())), + _FakeSession(AsyncMock(return_value=_ok_result())), + ] + enter = _patch_enter(sessions, []) + pool = MCPConnectionPool() + + with patch("vibe.core.tools.mcp.pool.enter_stdio_session", enter): + await pool.call_tool( + command=["srv"], tool_name="t", arguments={}, env={"K": "1"} + ) + await pool.call_tool( + command=["srv"], tool_name="t", arguments={}, env={"K": "2"} + ) + + assert enter.call_count == 2 + + @pytest.mark.asyncio + async def test_aclose_closes_all_connections(self): + closed: list[_FakeSession] = [] + sessions = [ + _FakeSession(AsyncMock(return_value=_ok_result())), + _FakeSession(AsyncMock(return_value=_ok_result())), + ] + enter = _patch_enter(sessions, closed) + pool = MCPConnectionPool() + + with patch("vibe.core.tools.mcp.pool.enter_stdio_session", enter): + await pool.call_tool(command=["srv-a"], tool_name="t", arguments={}) + await pool.call_tool(command=["srv-b"], tool_name="t", arguments={}) + await pool.aclose() + + assert closed == sessions + assert pool._conns == {} + + @pytest.mark.asyncio + async def test_tool_error_does_not_reconnect(self): + err = RuntimeError("tool blew up") + session = _FakeSession(AsyncMock(side_effect=err)) + enter = _patch_enter([session], []) + pool = MCPConnectionPool() + + with patch("vibe.core.tools.mcp.pool.enter_stdio_session", enter): + with pytest.raises(RuntimeError, match="tool blew up"): + await pool.call_tool(command=["srv"], tool_name="t", arguments={}) + + assert enter.call_count == 1 + + @pytest.mark.asyncio + async def test_bind_loop_drops_connections_on_loop_change(self): + pool = MCPConnectionPool() + other_loop = asyncio.new_event_loop() + try: + pool._loop = other_loop + pool._conns["k"] = _StdioConnection(build_stdio_params(["srv"]), None, None) + pool._bind_loop() + assert pool._conns == {} + assert pool._loop is asyncio.get_running_loop() + finally: + other_loop.close() + + def test_stdio_key_stable_and_distinct(self): + base = stdio_key(["srv", "--x"], {"A": "1"}, "/tmp") + assert base == stdio_key(["srv", "--x"], {"A": "1"}, "/tmp") + assert base != stdio_key(["srv", "--y"], {"A": "1"}, "/tmp") + assert base != stdio_key(["srv", "--x"], {"A": "2"}, "/tmp") + assert base != stdio_key(["srv", "--x"], {"A": "1"}, "/other") + + +class TestMCPStdioProxyToolPooling: + @staticmethod + def _make_tool(): + cls = create_mcp_stdio_proxy_tool_class( + command=["srv"], remote=RemoteTool(name="t"), alias="local" + ) + return cls(lambda: BaseToolConfig(), BaseToolState()) + + @pytest.mark.asyncio + async def test_run_routes_through_pool_when_present(self): + tool = self._make_tool() + pool = MagicMock() + pool.call_tool = AsyncMock( + return_value=MCPToolResult(server="s", tool="t", text="ok") + ) + ctx = InvokeContext(tool_call_id="1", mcp_pool=pool) + + with patch("vibe.core.tools.mcp.tools.call_tool_stdio") as one_shot: + results = [ev async for ev in tool.run(_OpenArgs(), ctx)] + + one_shot.assert_not_called() + pool.call_tool.assert_awaited_once() + assert isinstance(results[0], MCPToolResult) + assert results[0].text == "ok" + + @pytest.mark.asyncio + async def test_run_falls_back_without_pool(self): + tool = self._make_tool() + ctx = InvokeContext(tool_call_id="1", mcp_pool=None) + + with patch( + "vibe.core.tools.mcp.tools.call_tool_stdio", + AsyncMock(return_value=MCPToolResult(server="s", tool="t", text="fb")), + ) as one_shot: + results = [ev async for ev in tool.run(_OpenArgs(), ctx)] + + one_shot.assert_awaited_once() + assert isinstance(results[0], MCPToolResult) + assert results[0].text == "fb" + + +_COUNTER_SERVER = """ +import os +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("counter") +_state = {"n": 0} + + +@mcp.tool() +def increment() -> str: + _state["n"] += 1 + return f"count={_state['n']} pid={os.getpid()}" + + +if __name__ == "__main__": + mcp.run() +""" + + +def _result_text(result: MCPToolResult) -> str: + if result.text: + return result.text + return str(result.structured) + + +class TestMCPConnectionPoolIntegration: + @pytest.mark.asyncio + async def test_persists_real_subprocess_state_across_calls(self, tmp_path): + script = tmp_path / "counter_server.py" + script.write_text(_COUNTER_SERVER) + command = [sys.executable, str(script)] + pool = MCPConnectionPool() + + try: + r1 = await pool.call_tool( + command=command, + tool_name="increment", + arguments={}, + startup_timeout_sec=30, + tool_timeout_sec=30, + ) + r2 = await pool.call_tool( + command=command, + tool_name="increment", + arguments={}, + startup_timeout_sec=30, + tool_timeout_sec=30, + ) + finally: + await pool.aclose() + + t1, t2 = _result_text(r1), _result_text(r2) + # State survives across calls only if the same subprocess handled both. + assert "count=1" in t1 + assert "count=2" in t2 + pid1 = re.search(r"pid=(\d+)", t1) + pid2 = re.search(r"pid=(\d+)", t2) + assert pid1 is not None and pid2 is not None + assert pid1.group(1) == pid2.group(1) + + @pytest.mark.asyncio + async def test_aclose_terminates_real_subprocess(self, tmp_path): + script = tmp_path / "counter_server.py" + script.write_text(_COUNTER_SERVER) + command = [sys.executable, str(script)] + pool = MCPConnectionPool() + + result = await pool.call_tool( + command=command, + tool_name="increment", + arguments={}, + startup_timeout_sec=30, + tool_timeout_sec=30, + ) + pid_match = re.search(r"pid=(\d+)", _result_text(result)) + assert pid_match is not None + pid = int(pid_match.group(1)) + + await pool.aclose() + + # The subprocess should be gone after aclose; poll briefly to allow the + # OS to reap it. + for _ in range(50): + try: + os.kill(pid, 0) + except ProcessLookupError: + break + await asyncio.sleep(0.1) + else: + pytest.fail(f"MCP subprocess pid={pid} still alive after aclose") diff --git a/tests/tools/test_mcp_registry_oauth.py b/tests/tools/test_mcp_registry_oauth.py new file mode 100644 index 0000000..8b72260 --- /dev/null +++ b/tests/tools/test_mcp_registry_oauth.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Iterator +from typing import Any, ClassVar +from unittest.mock import AsyncMock, patch + +import keyring +from keyring.backend import KeyringBackend +import keyring.errors +from mcp.client.auth import OAuthFlowError +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken +import pytest + +from vibe.core.auth.mcp_oauth import ( + Fingerprint, + KeyringTokenStorage, + MCPOAuthLoginFailed, +) +from vibe.core.config import MCPOAuth, MCPStreamableHttp +from vibe.core.tools.base import BaseToolConfig, InvokeContext, ToolError +from vibe.core.tools.mcp import AuthStatus, MCPRegistry, MCPToolResult, RemoteTool +from vibe.core.tools.mcp.tools import ( + MCPHttpOAuthRuntime, + _OpenArgs, + create_mcp_http_proxy_tool_class, +) + + +class MemoryKeyring(KeyringBackend): + priority: ClassVar[Any] = 100 + + def __init__(self) -> None: + self.store: dict[tuple[str, str], str] = {} + + def get_password(self, service: str, username: str) -> str | None: + return self.store.get((service, username)) + + def set_password(self, service: str, username: str, password: str) -> None: + self.store[(service, username)] = password + + def delete_password(self, service: str, username: str) -> None: + if (service, username) not in self.store: + raise keyring.errors.PasswordDeleteError(username) + del self.store[(service, username)] + + +@pytest.fixture +def memory_keyring() -> Iterator[MemoryKeyring]: + original = keyring.get_keyring() + fake = MemoryKeyring() + keyring.set_keyring(fake) + try: + yield fake + finally: + keyring.set_keyring(original) + + +def _oauth_server( + *, + name: str = "linear", + url: str = "https://mcp.example.com/mcp", + scopes: list[str] | None = None, +) -> MCPStreamableHttp: + return MCPStreamableHttp( + name=name, + transport="streamable-http", + url=url, + auth=MCPOAuth(type="oauth", scopes=scopes if scopes is not None else ["read"]), + ) + + +async def _save_valid_oauth_state(srv: MCPStreamableHttp) -> None: + storage = KeyringTokenStorage(alias=srv.name) + await storage.set_tokens( + OAuthToken( + access_token="ACCESS", + token_type="Bearer", + expires_in=3600, + refresh_token="REFRESH", + ) + ) + await Fingerprint.compute(srv).save(srv.name) + + +class TestMCPRegistryOAuthDiscovery: + @pytest.mark.asyncio + async def test_missing_tokens_mark_needs_auth_without_caching( + self, memory_keyring: MemoryKeyring + ) -> None: + srv = _oauth_server() + registry = MCPRegistry() + + tools = await registry.get_tools_async([srv]) + + assert tools == {} + assert registry.needs_auth == {"linear"} + assert registry.status()["linear"] == AuthStatus.NEEDS_AUTH + assert registry.count_loaded([srv]) == 0 + + @pytest.mark.asyncio + async def test_removed_oauth_server_clears_auth_status( + self, memory_keyring: MemoryKeyring + ) -> None: + srv = _oauth_server() + registry = MCPRegistry() + + await registry.get_tools_async([srv]) + await registry.get_tools_async([]) + + assert registry.needs_auth == set() + assert registry.status() == {} + + @pytest.mark.asyncio + async def test_valid_tokens_register_tools_with_oauth_provider( + self, memory_keyring: MemoryKeyring + ) -> None: + srv = _oauth_server() + await _save_valid_oauth_state(srv) + registry = MCPRegistry() + remote = RemoteTool(name="create_issue") + + with patch( + "vibe.core.tools.mcp.registry.list_tools_http", + new=AsyncMock(return_value=[remote]), + ) as list_tools: + tools = await registry.get_tools_async([srv]) + + assert "linear_create_issue" in tools + assert registry.needs_auth == set() + assert registry.status()["linear"] == AuthStatus.OK + await_args = list_tools.await_args + assert await_args is not None + assert await_args.kwargs["auth"] is not None + + @pytest.mark.asyncio + async def test_fingerprint_mismatch_deletes_tokens_and_requires_auth( + self, memory_keyring: MemoryKeyring + ) -> None: + original = _oauth_server(scopes=["read"]) + changed = _oauth_server(scopes=["read", "write"]) + await _save_valid_oauth_state(original) + storage = KeyringTokenStorage(alias="linear") + await storage.set_client_info( + OAuthClientInformationFull.model_validate({ + "client_id": "client", + "redirect_uris": ["http://127.0.0.1:47823/callback"], + "token_endpoint_auth_method": "none", + }) + ) + registry = MCPRegistry() + + tools = await registry.get_tools_async([changed]) + + assert tools == {} + assert registry.needs_auth == {"linear"} + assert await storage.get_tokens() is None + assert await storage.get_client_info() is None + assert await Fingerprint.load("linear") is None + + @pytest.mark.asyncio + async def test_login_clears_needs_auth_and_rediscovers( + self, memory_keyring: MemoryKeyring + ) -> None: + srv = _oauth_server() + registry = MCPRegistry() + await registry.get_tools_async([srv]) + + async def perform_login(_srv: MCPStreamableHttp, *, on_url: object) -> None: + await _save_valid_oauth_state(_srv) + + with ( + patch( + "vibe.core.tools.mcp.registry.perform_oauth_login", + new=AsyncMock(side_effect=perform_login), + ), + patch( + "vibe.core.tools.mcp.registry.list_tools_http", + new=AsyncMock(return_value=[RemoteTool(name="search")]), + ), + ): + await registry.login("linear", on_url=AsyncMock()) + + assert registry.needs_auth == set() + assert registry.status()["linear"] == AuthStatus.OK + tools = await registry.get_tools_async([srv]) + assert "linear_search" in tools + + @pytest.mark.asyncio + async def test_login_keeps_needs_auth_when_discovery_fails( + self, memory_keyring: MemoryKeyring + ) -> None: + srv = _oauth_server() + registry = MCPRegistry() + await registry.get_tools_async([srv]) + + async def perform_login(_srv: MCPStreamableHttp, *, on_url: object) -> None: + await _save_valid_oauth_state(_srv) + + with ( + patch( + "vibe.core.tools.mcp.registry.perform_oauth_login", + new=AsyncMock(side_effect=perform_login), + ), + patch( + "vibe.core.tools.mcp.registry.list_tools_http", + new=AsyncMock(side_effect=RuntimeError("boom")), + ), + ): + with pytest.raises(MCPOAuthLoginFailed): + await registry.login("linear", on_url=AsyncMock()) + + assert registry.needs_auth == {"linear"} + assert registry.status()["linear"] == AuthStatus.NEEDS_AUTH + + @pytest.mark.asyncio + async def test_mark_oauth_failure_drops_cached_tools( + self, memory_keyring: MemoryKeyring + ) -> None: + srv = _oauth_server() + await _save_valid_oauth_state(srv) + registry = MCPRegistry() + + with patch( + "vibe.core.tools.mcp.registry.list_tools_http", + new=AsyncMock(return_value=[RemoteTool(name="search")]), + ): + tools = await registry.get_tools_async([srv]) + + assert "linear_search" in tools + assert registry.count_loaded([srv]) == 1 + + await registry.mark_oauth_failure("linear") + + assert registry.needs_auth == {"linear"} + assert registry.count_loaded([srv]) == 0 + + @pytest.mark.asyncio + async def test_logout_deletes_tokens_client_info_and_fingerprint( + self, memory_keyring: MemoryKeyring + ) -> None: + srv = _oauth_server() + await _save_valid_oauth_state(srv) + storage = KeyringTokenStorage(alias="linear") + await storage.set_client_info( + OAuthClientInformationFull.model_validate({ + "client_id": "client", + "redirect_uris": ["http://127.0.0.1:47823/callback"], + "token_endpoint_auth_method": "none", + }) + ) + registry = MCPRegistry() + with patch( + "vibe.core.tools.mcp.registry.list_tools_http", + new=AsyncMock(return_value=[RemoteTool(name="search")]), + ): + await registry.get_tools_async([srv]) + + await registry.logout("linear") + + assert registry.needs_auth == {"linear"} + assert await storage.get_tokens() is None + assert await Fingerprint.load("linear") is None + assert await storage.get_client_info() is None + + +class TestMCPHttpOAuthProxy: + @pytest.mark.asyncio + async def test_oauth_flow_error_marks_auth_failure_with_stop_turn_message( + self, + ) -> None: + callback = AsyncMock() + tool_cls = create_mcp_http_proxy_tool_class( + url="https://mcp.example.com/mcp", + remote=RemoteTool(name="search"), + alias="linear", + oauth_runtime=MCPHttpOAuthRuntime( + lock=asyncio.Lock(), failure_callback=callback + ), + ) + tool = tool_cls.from_config(lambda: BaseToolConfig()) + + with patch( + "vibe.core.tools.mcp.tools.call_tool_http", + new=AsyncMock(side_effect=OAuthFlowError("invalid_grant")), + ): + with pytest.raises(ToolError, match="lost authentication"): + async for _ in tool.run(_OpenArgs(), InvokeContext(tool_call_id="tc")): + pass + + callback.assert_awaited_once_with("linear") + + @pytest.mark.asyncio + async def test_oauth_lock_serializes_concurrent_calls(self) -> None: + active = 0 + max_active = 0 + + async def fake_call(*_args: object, **_kwargs: object) -> MCPToolResult: + nonlocal active, max_active + active += 1 + max_active = max(max_active, active) + await asyncio.sleep(0.01) + active -= 1 + return MCPToolResult(server="linear", tool="search") + + tool_cls = create_mcp_http_proxy_tool_class( + url="https://mcp.example.com/mcp", + remote=RemoteTool(name="search"), + alias="linear", + oauth_runtime=MCPHttpOAuthRuntime( + lock=asyncio.Lock(), failure_callback=AsyncMock() + ), + ) + + async def run_once() -> None: + tool = tool_cls.from_config(lambda: BaseToolConfig()) + async for _ in tool.run(_OpenArgs(), InvokeContext(tool_call_id="tc")): + pass + + with patch("vibe.core.tools.mcp.tools.call_tool_http", new=fake_call): + await asyncio.gather(run_once(), run_once()) + + assert max_active == 1 diff --git a/tests/tools/test_task.py b/tests/tools/test_task.py index fd1651d..e038bd0 100644 --- a/tests/tools/test_task.py +++ b/tests/tools/test_task.py @@ -1,6 +1,6 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -228,3 +228,44 @@ class TestTaskToolExecution: assert isinstance(result, TaskResult) assert result.completed is False assert "Simulated error" in result.response + + @pytest.mark.asyncio + async def test_closes_subagent_loop_on_success( + self, task_tool: Task, ctx: InvokeContext + ) -> None: + async def mock_act(task: str): + yield AssistantEvent(content="done") + + with patch("vibe.core.tools.builtins.task.AgentLoop") as mock_agent_loop_class: + mock_agent_loop = MagicMock() + mock_agent_loop.act = mock_act + mock_agent_loop.messages = [LLMMessage(role=Role.assistant, content="a")] + mock_agent_loop.set_approval_callback = MagicMock() + mock_agent_loop.aclose = AsyncMock() + mock_agent_loop_class.return_value = mock_agent_loop + + args = TaskArgs(task="do something", agent="explore") + await collect_result(task_tool.run(args, ctx)) + + mock_agent_loop.aclose.assert_awaited_once() + + @pytest.mark.asyncio + async def test_closes_subagent_loop_on_exception( + self, task_tool: Task, ctx: InvokeContext + ) -> None: + async def mock_act(task: str): + yield AssistantEvent(content="starting") + raise RuntimeError("boom") + + with patch("vibe.core.tools.builtins.task.AgentLoop") as mock_agent_loop_class: + mock_agent_loop = MagicMock() + mock_agent_loop.act = mock_act + mock_agent_loop.messages = [LLMMessage(role=Role.assistant, content="a")] + mock_agent_loop.set_approval_callback = MagicMock() + mock_agent_loop.aclose = AsyncMock() + mock_agent_loop_class.return_value = mock_agent_loop + + args = TaskArgs(task="do something", agent="explore") + await collect_result(task_tool.run(args, ctx)) + + mock_agent_loop.aclose.assert_awaited_once() diff --git a/tests/update_notifier/test_update_use_case.py b/tests/update_notifier/test_update_use_case.py index 11e26b3..28688c3 100644 --- a/tests/update_notifier/test_update_use_case.py +++ b/tests/update_notifier/test_update_use_case.py @@ -206,6 +206,34 @@ async def test_does_not_notify_when_an_available_update_has_been_recently_cached assert update_notifier.fetch_update_calls == 0 +@pytest.mark.asyncio +async def test_force_check_ignores_fresh_cache_and_notifies( + current_timestamp: int, +) -> None: + update_notifier = FakeUpdateGateway(update=Update(latest_version="1.0.2")) + timestamp_twelve_hours_ago = current_timestamp - 12 * 60 * 60 + update_cache = UpdateCache( + latest_version="1.0.1", stored_at_timestamp=timestamp_twelve_hours_ago + ) + update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache) + + update = await get_update_if_available( + update_notifier, + current_version="1.0.0", + update_cache_repository=update_cache_repository, + get_current_timestamp=lambda: current_timestamp, + force_check=True, + ) + + assert update is not None + assert update.latest_version == "1.0.2" + assert update.should_notify is True + assert update_notifier.fetch_update_calls == 1 + assert update_cache_repository.update_cache is not None + assert update_cache_repository.update_cache.latest_version == "1.0.2" + assert update_cache_repository.update_cache.stored_at_timestamp == current_timestamp + + @pytest.mark.asyncio async def test_retrieves_nothing_when_the_recently_cached_update_is_the_one_currently_in_use( current_timestamp: int, diff --git a/uv.lock b/uv.lock index 60a684e..a1aeba9 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.12" [options] exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. -exclude-newer-span = "P7D" +exclude-newer-span = "P2D" [[package]] name = "agent-client-protocol" @@ -251,55 +251,55 @@ wheels = [ [[package]] name = "cryptography" -version = "47.0.0" +version = "48.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, - { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, - { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, - { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, - { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, - { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, - { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, - { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, - { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, - { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, - { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, - { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, - { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, - { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, - { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, - { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, - { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, - { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, - { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, - { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, - { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, + { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, + { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, ] [[package]] @@ -779,7 +779,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.27.1" +version = "1.27.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -797,9 +797,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" } +sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" }, + { url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, ] [[package]] @@ -825,7 +825,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.16.1" +version = "2.17.0" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, @@ -968,7 +968,7 @@ requires-dist = [ { name = "charset-normalizer", specifier = "==3.4.7" }, { name = "click", marker = "sys_platform != 'emscripten'", specifier = "==8.3.3" }, { name = "colorama", marker = "sys_platform == 'win32'", specifier = "==0.4.6" }, - { name = "cryptography", specifier = "==47.0.0" }, + { name = "cryptography", specifier = "==48.0.1" }, { name = "eval-type-backport", specifier = "==0.3.1" }, { name = "gitdb", specifier = "==4.0.12" }, { name = "gitpython", specifier = "==3.1.47" }, @@ -995,10 +995,10 @@ requires-dist = [ { name = "linkify-it-py", specifier = "==2.1.0" }, { name = "markdown-it-py", specifier = "==4.0.0" }, { name = "markdownify", specifier = "==1.2.2" }, - { name = "mcp", specifier = "==1.27.1" }, + { name = "mcp", specifier = "==1.27.2" }, { name = "mdit-py-plugins", specifier = "==0.5.0" }, { name = "mdurl", specifier = "==0.1.2" }, - { name = "mistralai", specifier = "==2.4.4" }, + { name = "mistralai", specifier = "==2.4.9" }, { name = "more-itertools", specifier = "==11.0.2" }, { name = "opentelemetry-api", specifier = "==1.39.1" }, { name = "opentelemetry-exporter-otlp-proto-common", specifier = "==1.39.1" }, @@ -1018,11 +1018,11 @@ requires-dist = [ { name = "pydantic-core", specifier = "==2.46.3" }, { name = "pydantic-settings", specifier = "==2.14.0" }, { name = "pygments", specifier = "==2.20.0" }, - { name = "pyjwt", specifier = "==2.12.1" }, + { name = "pyjwt", specifier = "==2.13.0" }, { name = "pyperclip", specifier = "==1.11.0" }, { name = "python-dateutil", specifier = "==2.9.0.post0" }, { name = "python-dotenv", specifier = "==1.2.2" }, - { name = "python-multipart", specifier = "==0.0.27" }, + { name = "python-multipart", specifier = "==0.0.32" }, { name = "pywin32", marker = "sys_platform == 'win32'", specifier = "==311" }, { name = "pywin32-ctypes", marker = "sys_platform == 'win32'", specifier = "==0.2.3" }, { name = "pyyaml", specifier = "==6.0.3" }, @@ -1046,7 +1046,7 @@ requires-dist = [ { name = "typing-extensions", specifier = "==4.15.0" }, { name = "typing-inspection", specifier = "==0.4.2" }, { name = "uc-micro-py", specifier = "==2.0.0" }, - { name = "urllib3", specifier = "==2.6.3" }, + { name = "urllib3", specifier = "==2.7.0" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = "==0.46.0" }, { name = "watchfiles", specifier = "==1.1.1" }, { name = "websockets", specifier = "==16.0" }, @@ -1086,7 +1086,7 @@ dev = [ [[package]] name = "mistralai" -version = "2.4.4" +version = "2.4.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "eval-type-backport" }, @@ -1098,9 +1098,9 @@ dependencies = [ { name = "python-dateutil" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f0/f0/80dfabf224be4419c6c112f3950676f5af3dcde582d225c03ca0196a4b32/mistralai-2.4.4.tar.gz", hash = "sha256:cd8a27a230e5458b62237a6c4f7b52f5be86909fbc18694360ceb21dac932eda", size = 420936, upload-time = "2026-04-30T12:26:38.775Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/f1/f22fa0fac767279df95e0796f37520eae0678a75b62d7bca3e85c46b684b/mistralai-2.4.9.tar.gz", hash = "sha256:9a6465b8bddf825d76cf80ab54d55bb8089fdf5aafc0a7943ae74c825df97939", size = 471000, upload-time = "2026-06-03T13:04:22.565Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/74/0f6188190e79eef4363754c71414c1a791537b1e506cbee615bb581cb05f/mistralai-2.4.4-py3-none-any.whl", hash = "sha256:43dd3b1f0f4f960a723359165c4da0d035590b27c050028322934fe4f189f14e", size = 990545, upload-time = "2026-04-30T12:26:40.702Z" }, + { url = "https://files.pythonhosted.org/packages/48/bc/977b65cddce185dce0414d14c783040a9f9d647bbfe9b0f5c74ff9262816/mistralai-2.4.9-py3-none-any.whl", hash = "sha256:8e2f8a58697455f2113b4f6d0d1bd28d66d3eb51f94ff24b9d22a21dea531520", size = 1121336, upload-time = "2026-06-03T13:04:20.694Z" }, ] [[package]] @@ -1551,11 +1551,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -1691,11 +1691,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.27" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] @@ -2288,11 +2288,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] diff --git a/vibe/__init__.py b/vibe/__init__.py index 713349f..af8f90d 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.16.1" +__version__ = "2.17.0" diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py index 0c04137..ae98164 100644 --- a/vibe/acp/acp_agent_loop.py +++ b/vibe/acp/acp_agent_loop.py @@ -10,7 +10,7 @@ import os from pathlib import Path import signal import sys -from typing import Any, Literal, Protocol, cast, override +from typing import Any, Protocol, cast, override from uuid import uuid4 from acp import ( @@ -21,7 +21,6 @@ from acp import ( LoadSessionResponse, NewSessionResponse, PromptResponse, - RequestError, SetSessionModelResponse, SetSessionModeResponse, run_agent, @@ -69,6 +68,7 @@ from acp.schema import ( Usage, UsageUpdate, ) +from keyring.errors import KeyringError from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError from vibe import VIBE_ROOT, __version__ @@ -79,6 +79,7 @@ from vibe.acp.exceptions import ( ConfigurationError, ContextTooLongError, ConversationLimitError, + ImagesNotSupportedError as AcpImagesNotSupportedError, InternalError, InvalidRequestError, NotImplementedMethodError, @@ -88,6 +89,7 @@ from vibe.acp.exceptions import ( SessionNotFoundError, UnauthenticatedError, ) +from vibe.acp.image_blocks import extract_image_attachments from vibe.acp.session import AcpSessionLoop from vibe.acp.teleport import handle_teleport_command from vibe.acp.title import acp_blocks_to_title_segments @@ -98,6 +100,10 @@ from vibe.acp.tools.session_update import ( tool_call_session_update, tool_result_session_update, ) +from vibe.acp.user_display_content import ( + USER_DISPLAY_CONTENT_META_KEY, + parse_user_display_content_metadata, +) from vibe.acp.utils import ( THINKING_LEVELS, ThinkingLevel, @@ -109,17 +115,23 @@ from vibe.acp.utils import ( create_compact_end_session_update, create_compact_start_session_update, create_reasoning_replay, - create_tool_call_replay, create_tool_result_replay, create_user_message_replay, get_proxy_help_text, is_jetbrains_client, is_valid_acp_mode, make_thinking_response, + tool_call_replay_update, +) +from vibe.core.agent_loop import ( + AgentLoop, + CompactionFailedError, + ImagesNotSupportedError, ) -from vibe.core.agent_loop import AgentLoop, CompactionFailedError from vibe.core.agents.models import CHAT as CHAT_AGENT, BuiltinAgentName +from vibe.core.auth import MCPOAuthError from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt +from vibe.core.cache_store import FileSystemVibeCodeCacheStore from vibe.core.config import ( MissingAPIKeyError, ProviderConfig, @@ -148,13 +160,16 @@ from vibe.core.skills.manager import SkillManager from vibe.core.telemetry.build_metadata import build_entrypoint_metadata from vibe.core.telemetry.send import TelemetryClient from vibe.core.telemetry.types import EntrypointMetadata +from vibe.core.tools.mcp import AuthStatus from vibe.core.tools.permissions import RequiredPermission from vibe.core.trusted_folders import ( WorkspaceTrustDecision, WorkspaceTrustPrompt, + WorkspaceTrustStatus, apply_workspace_trust_decision, available_workspace_trust_decisions, maybe_build_workspace_trust_prompt, + trusted_folders_manager, ) from vibe.core.types import ( AgentProfileChangedEvent, @@ -164,6 +179,7 @@ from vibe.core.types import ( CompactEndEvent, CompactStartEvent, ContextTooLongError as CoreContextTooLongError, + ImageAttachment, LLMMessage, RateLimitError as CoreRateLimitError, ReasoningEvent, @@ -174,6 +190,7 @@ from vibe.core.types import ( ToolCallEvent, ToolResultEvent, ToolStreamEvent, + UserDisplayContentMetadata, ) from vibe.core.utils import ( CancellationReason, @@ -201,8 +218,23 @@ logger = logging.getLogger("vibe") NON_INTERACTIVE_DISABLED_TOOLS = ["ask_user_question", "exit_plan_mode"] INITIAL_AVAILABLE_COMMANDS_DELAY_SECONDS = 0.1 -WORKSPACE_TRUST_CAPABILITY = "workspace-trust" -TRUST_REQUEST_METHOD = "trust/request" +_MCP_COMMAND_MAX_SPLITS = 2 +_MCP_COMMAND_ARG_INDEX = 2 +WORKSPACE_TRUST_META_KEY = "workspace_trust" +TRUST_GRANT_DECISIONS = { + WorkspaceTrustDecision.TRUST_REPO, + WorkspaceTrustDecision.TRUST_CWD, + WorkspaceTrustDecision.TRUST_SESSION, +} + + +def _mcp_tui_login_message(alias: str) -> str: + return ( + "MCP OAuth login must be performed via the Vibe TUI. " + "Run `vibe` in a terminal and execute " + f"`/mcp login {alias}` there. Tokens are stored in the OS keyring; " + "run `/reload` in this ACP session afterward if the tools are not visible." + ) def _merge_non_interactive_disabled_tools(config: VibeConfig) -> None: @@ -242,22 +274,38 @@ class TelemetrySendNotification(BaseModel): session_id: str = Field(validation_alias=AliasChoices("session_id", "sessionId")) -class WorkspaceTrustRequest(BaseModel): +class WorkspaceTrustDetails(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) cwd: str repo_root: str | None = Field(default=None, alias="repoRoot") - detected_files: list[str] = Field(alias="detectedFiles") - repo_detected_files: list[str] = Field(alias="repoDetectedFiles") + ignored_files: list[str] = Field(alias="ignoredFiles") available_decisions: list[WorkspaceTrustDecision] = Field( alias="availableDecisions" ) -class WorkspaceTrustResponse(BaseModel): - model_config = ConfigDict(extra="forbid") +class WorkspaceTrustStatusRequest(BaseModel): + model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) - decision: WorkspaceTrustDecision | Literal["cancelled"] + cwd: str | None = None + + +class WorkspaceTrustDecisionRequest(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + cwd: str | None = None + decision: WorkspaceTrustDecision + session_id: str | None = Field( + default=None, validation_alias=AliasChoices("session_id", "sessionId") + ) + + +class WorkspaceTrustStatusResponse(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + trust_status: WorkspaceTrustStatus = Field(alias="trust_status") + details: WorkspaceTrustDetails | None = None class AuthStatusResponse(BaseModel): @@ -435,14 +483,6 @@ class VibeAcpAgentLoop(AcpAgent): is True ) - def _client_supports_workspace_trust(self) -> bool: - return bool( - self.client_capabilities - and self.client_capabilities.field_meta - and self.client_capabilities.field_meta.get(WORKSPACE_TRUST_CAPABILITY) - is True - ) - @override async def initialize( self, @@ -502,7 +542,7 @@ class VibeAcpAgentLoop(AcpAgent): agent_capabilities=AgentCapabilities( load_session=True, prompt_capabilities=PromptCapabilities( - audio=False, embedded_context=True, image=False + audio=False, embedded_context=True, image=True ), session_capabilities=SessionCapabilities( close=SessionCloseCapabilities(), @@ -689,7 +729,7 @@ class VibeAcpAgentLoop(AcpAgent): agent_loop.set_approval_callback(self._create_approval_callback(session.id)) session.spawn(self._send_initial_available_commands(session)) - session.spawn(self._warm_up_agent_loop(agent_loop)) + session.spawn(self._warm_up_agent_loop(session)) return session @@ -699,16 +739,41 @@ class VibeAcpAgentLoop(AcpAgent): await asyncio.sleep(INITIAL_AVAILABLE_COMMANDS_DELAY_SECONDS) await self._send_available_commands(session) - async def _warm_up_agent_loop(self, agent_loop: AgentLoop) -> None: + async def _warm_up_agent_loop(self, session: AcpSessionLoop) -> None: """Proactively await deferred init so `vibe.ready` telemetry is emitted without waiting for the user's first prompt. Errors are swallowed here and will resurface on the first `act()` call via `requires_init`. """ try: - await agent_loop.wait_until_ready() + await session.agent_loop.wait_until_ready() + await self._notify_mcp_auth_required(session) except Exception: pass + async def _notify_mcp_auth_required(self, session: AcpSessionLoop) -> None: + statuses = session.agent_loop.mcp_registry.status() + aliases = sorted( + alias + for alias, status in statuses.items() + if status is AuthStatus.NEEDS_AUTH + ) + if not aliases: + return + message = _mcp_tui_login_message(aliases[0]) + if len(aliases) > 1: + message = ( + "MCP OAuth login must be performed via the Vibe TUI for these " + f"servers first: {', '.join(aliases)}.\n\n{message}" + ) + await self.client.session_update( + session_id=session.id, + update=AgentMessageChunk( + session_update="agent_message_chunk", + content=TextContentBlock(type="text", text=message), + message_id=str(uuid4()), + ), + ) + def _create_agent_loop( self, config: VibeConfig, agent_name: str, hook_config_result: Any = None ) -> AgentLoop: @@ -719,6 +784,7 @@ class VibeAcpAgentLoop(AcpAgent): entrypoint_metadata=self._build_entrypoint_metadata(), defer_heavy_init=True, hook_config_result=hook_config_result, + cache_store=FileSystemVibeCodeCacheStore(), ) agent_loop.agent_manager.register_agent(CHAT_AGENT) return agent_loop @@ -735,55 +801,127 @@ class VibeAcpAgentLoop(AcpAgent): ) return modes_state, modes_config, models_state, models_config - def _build_workspace_trust_request( + def _workspace_trust_details( + self, cwd: Path + ) -> tuple[WorkspaceTrustStatus, WorkspaceTrustDetails | None]: + status = self._workspace_trust_status(cwd) + if status is not WorkspaceTrustStatus.UNTRUSTED: + return status, None + + prompt = maybe_build_workspace_trust_prompt( + cwd, include_explicitly_untrusted=True + ) + if prompt is None: + return status, None + + return status, self._build_workspace_trust_details(prompt) + + def _build_workspace_trust_details( self, prompt: WorkspaceTrustPrompt - ) -> WorkspaceTrustRequest: - return WorkspaceTrustRequest( + ) -> WorkspaceTrustDetails: + return WorkspaceTrustDetails( cwd=str(prompt.cwd.resolve()), - repoRoot=str(prompt.repo_root.resolve()) - if prompt.offer_repo_trust and prompt.repo_root - else None, - detectedFiles=prompt.detected_files, - repoDetectedFiles=prompt.repo_detected_files, + repoRoot=str(prompt.repo_root.resolve()) if prompt.repo_root else None, + ignoredFiles=self._workspace_trust_ignored_files(prompt), availableDecisions=available_workspace_trust_decisions( prompt, include_session=True ), ) - async def _resolve_workspace_trust(self, cwd: Path) -> None: - if not self._client_supports_workspace_trust(): - return + def _workspace_trust_ignored_files(self, prompt: WorkspaceTrustPrompt) -> list[str]: + found = set(prompt.repo_detected_files) + if prompt.repo_root is None: + found.update(prompt.detected_files) + return sorted(found) - prompt = maybe_build_workspace_trust_prompt(cwd) + try: + cwd_relative = prompt.cwd.resolve().relative_to(prompt.repo_root.resolve()) + except ValueError: + found.update(prompt.detected_files) + return sorted(found) + + cwd_prefix = "" if cwd_relative == Path(".") else cwd_relative.as_posix() + for file in prompt.detected_files: + found.add(file if not cwd_prefix else f"{cwd_prefix}/{file}") + return sorted(found) + + def _workspace_trust_status(self, cwd: Path) -> WorkspaceTrustStatus: + return trusted_folders_manager.trust_status(cwd) + + def _workspace_trust_response(self, cwd: Path) -> WorkspaceTrustStatusResponse: + status, details = self._workspace_trust_details(cwd) + return WorkspaceTrustStatusResponse(trust_status=status, details=details) + + def _workspace_trust_meta(self, cwd: Path) -> dict[str, Any]: + response = self._workspace_trust_response(cwd).model_dump( + mode="json", by_alias=True + ) + return { + WORKSPACE_TRUST_META_KEY: { + "status": response["trust_status"], + "details": response["details"], + } + } + + def _workspace_trust_prompt_for_decision( + self, cwd: Path, decision: WorkspaceTrustDecision + ) -> WorkspaceTrustPrompt: + prompt = maybe_build_workspace_trust_prompt( + cwd, include_explicitly_untrusted=True + ) if prompt is None: - return + raise InvalidRequestError("No workspace trust decision is available.") - request = self._build_workspace_trust_request(prompt) + available_decisions = available_workspace_trust_decisions( + prompt, include_session=True + ) + if decision not in available_decisions: + raise InvalidRequestError(f"Unsupported trust decision: {decision}") + return prompt + + async def _handle_workspace_trust_status(self, params: dict) -> dict[str, Any]: try: - raw_response = await self.client.ext_method( - TRUST_REQUEST_METHOD, request.model_dump(mode="json", by_alias=True) - ) - except RequestError as exc: - if exc.code == NotImplementedMethodError.code: - return - raise - - try: - response = WorkspaceTrustResponse.model_validate(raw_response) + request = WorkspaceTrustStatusRequest.model_validate(params) except ValidationError as exc: raise InvalidRequestError( - f"Invalid ACP trust decision response: {exc}" + f"Invalid ACP workspace trust status request: {exc}" ) from exc - if response.decision == "cancelled": - raise InvalidRequestError("Workspace trust prompt was cancelled.") + cwd = Path(request.cwd).expanduser().resolve() if request.cwd else Path.cwd() + return self._workspace_trust_response(cwd).model_dump( + mode="json", by_alias=True + ) + + async def _handle_workspace_trust_decision(self, params: dict) -> dict[str, Any]: + try: + request = WorkspaceTrustDecisionRequest.model_validate(params) + except ValidationError as exc: + raise InvalidRequestError( + f"Invalid ACP workspace trust decision request: {exc}" + ) from exc + + cwd = Path(request.cwd).expanduser().resolve() if request.cwd else Path.cwd() + session = self.sessions.get(request.session_id) if request.session_id else None + if request.session_id is not None and session is None: + raise SessionNotFoundError(request.session_id) + + prompt = self._workspace_trust_prompt_for_decision(cwd, request.decision) try: - apply_workspace_trust_decision(prompt, response.decision) + apply_workspace_trust_decision(prompt, request.decision) except ValueError as exc: raise InvalidRequestError(str(exc)) from exc + if session is not None and request.decision in TRUST_GRANT_DECISIONS: + os.chdir(cwd) + await self._reload_session_config(session) + await session.command_registry.notify_changed() + + return self._workspace_trust_response(cwd).model_dump( + mode="json", by_alias=True + ) + @override async def new_session( self, @@ -794,7 +932,6 @@ class VibeAcpAgentLoop(AcpAgent): ) -> NewSessionResponse: load_dotenv_values() os.chdir(cwd) - await self._resolve_workspace_trust(Path.cwd()) config = self._load_config() hook_config_result = load_hooks_from_fs(config) @@ -820,6 +957,7 @@ class VibeAcpAgentLoop(AcpAgent): models=models_state, modes=modes_state, config_options=self._build_config_options(session), + field_meta=self._workspace_trust_meta(Path.cwd()), ) def _get_acp_tool_overrides(self) -> list[Path]: @@ -978,19 +1116,33 @@ class VibeAcpAgentLoop(AcpAgent): session.spawn(_send()) - async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None: + async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> set[str]: if not msg.tool_calls: - return + return set() + + agent_loop = self._get_session(session_id).agent_loop + parsed = agent_loop.format_handler.parse_message(msg) + resolved = agent_loop.format_handler.resolve_tool_calls( + parsed, agent_loop.tool_manager + ) + resolved_by_id = {call.call_id: call for call in resolved.tool_calls} + + emitted_call_ids: set[str] = set() for tool_call in msg.tool_calls: - if tool_call.id and tool_call.function.name: - update = create_tool_call_replay( - tool_call.id, tool_call.function.name, tool_call.function.arguments - ) + if not (tool_call.id and tool_call.function.name): + continue + update = tool_call_replay_update( + resolved_by_id.get(tool_call.id), tool_call + ) + if update: + emitted_call_ids.add(tool_call.id) await self.client.session_update(session_id=session_id, update=update) + return emitted_call_ids async def _replay_conversation_history( self, session_id: str, messages: list[LLMMessage] ) -> None: + replayed_call_ids: set[str] = set() for msg in messages: if msg.role == Role.user: update = create_user_message_replay(msg) @@ -1005,9 +1157,14 @@ class VibeAcpAgentLoop(AcpAgent): await self.client.session_update( session_id=session_id, update=text_update ) - await self._replay_tool_calls(session_id, msg) + replayed_call_ids |= await self._replay_tool_calls(session_id, msg) elif msg.role == Role.tool: + # Skip results whose call was not replayed (e.g. hidden tools + # like todo): emitting one would orphan a tool_call_update with + # no preceding tool_call. + if msg.tool_call_id not in replayed_call_ids: + continue if result_update := create_tool_result_replay(msg): await self.client.session_update( session_id=session_id, update=result_update @@ -1059,7 +1216,6 @@ class VibeAcpAgentLoop(AcpAgent): ) -> LoadSessionResponse | None: load_dotenv_values() os.chdir(cwd) - await self._resolve_workspace_trust(Path.cwd()) config = self._load_config() hook_config_result = load_hooks_from_fs(config) @@ -1101,6 +1257,7 @@ class VibeAcpAgentLoop(AcpAgent): models=models_state, modes=modes_state, config_options=self._build_config_options(session), + field_meta=self._workspace_trust_meta(Path.cwd()), ) async def _apply_mode_change(self, session: AcpSessionLoop, mode_id: str) -> bool: @@ -1241,6 +1398,15 @@ class VibeAcpAgentLoop(AcpAgent): "Concurrent prompts are not supported yet, wait for agent loop to finish" ) + try: + user_display_content = parse_user_display_content_metadata( + kwargs.get(USER_DISPLAY_CONTENT_META_KEY) + ) + except ValidationError as e: + raise InvalidRequestError( + f"Invalid user display content metadata: {e}" + ) from e + text_prompt = self._build_text_prompt(prompt) resolved_message_id = _resolved_user_message_id(message_id) @@ -1266,9 +1432,18 @@ class VibeAcpAgentLoop(AcpAgent): format_session_title(acp_blocks_to_title_segments(prompt)) or None ) + images = extract_image_attachments( + prompt, session_dir=session.agent_loop.session_logger.session_dir + ) + async def agent_loop_task() -> None: async for update in self._run_agent_loop( - session, text_prompt, resolved_message_id, auto_title=auto_title + session, + text_prompt, + resolved_message_id, + auto_title=auto_title, + user_display_content=user_display_content, + images=images, ): await self.client.session_update(session_id=session.id, update=update) @@ -1307,6 +1482,9 @@ class VibeAcpAgentLoop(AcpAgent): except ConversationLimitException as e: raise ConversationLimitError(str(e)) from e + except ImagesNotSupportedError as e: + raise AcpImagesNotSupportedError(str(e)) from e + except Exception as e: raise InternalError(str(e)) from e @@ -1328,9 +1506,10 @@ class VibeAcpAgentLoop(AcpAgent): telemetry_active=agent_loop.telemetry_client.is_active(), is_mistral_model=agent_loop.config.is_active_model_mistral(), user_message_count=user_message_count, + cache_store=agent_loop.cache_store, ): return None - record_feedback_asked() + record_feedback_asked(agent_loop.cache_store) return {"show_feedback_prompt": True} def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str: @@ -1383,6 +1562,10 @@ class VibeAcpAgentLoop(AcpAgent): ] block_prompt = "\n".join(parts) text_prompt = f"{text_prompt}{separator}{block_prompt}" + case "image": + # Images carry no prompt text; they are extracted separately + # via extract_image_attachments and passed to act(images=...). + continue case _: raise InvalidRequestError( f"We currently don't support {block.type} content blocks" @@ -1413,6 +1596,8 @@ class VibeAcpAgentLoop(AcpAgent): client_message_id: str | None = None, *, auto_title: str | None = None, + user_display_content: UserDisplayContentMetadata | None = None, + images: list[ImageAttachment] | None = None, ) -> AsyncGenerator[SessionUpdate | UsageUpdate]: rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd()) @@ -1421,6 +1606,8 @@ class VibeAcpAgentLoop(AcpAgent): rendered_prompt, client_message_id=client_message_id, auto_title=auto_title, + user_display_content=user_display_content, + images=images or None, ) ) as events: async for event in events: @@ -1451,7 +1638,9 @@ class VibeAcpAgentLoop(AcpAgent): session_id=session.id, ) - session_update = tool_call_session_update(event) + # A starting tool call is pending until it streams or + # finishes; the host drops created events without a status. + session_update = tool_call_session_update(event, status="pending") if session_update: yield session_update @@ -1742,14 +1931,6 @@ class VibeAcpAgentLoop(AcpAgent): ) return provider, auth_state - def _process_env_value_before_dotenv_load( - self, provider: ProviderConfig - ) -> str | None: - if not provider.api_key_env_var: - return None - - return self._environ_before_dotenv_load.get(provider.api_key_env_var) - def _handle_auth_status(self) -> dict[str, Any]: _, auth_state = self._assess_current_auth_state() return _auth_status_response_from_auth_state(auth_state).model_dump( @@ -1765,15 +1946,7 @@ class VibeAcpAgentLoop(AcpAgent): try: self._remove_api_key(provider) - if ( - auth_state.kind - == AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV - and auth_state.env_key - ): - process_env_value = self._process_env_value_before_dotenv_load(provider) - if process_env_value: - os.environ[auth_state.env_key] = process_env_value - except (OSError, ValueError) as exc: + except (OSError, ValueError, KeyringError) as exc: raise InternalError(f"Failed to sign out: {exc}") from exc return {} @@ -1792,6 +1965,12 @@ class VibeAcpAgentLoop(AcpAgent): if method == "session/delete": return await self._handle_session_delete(params) + if method == "trust/status": + return await self._handle_workspace_trust_status(params) + + if method == "trust/decision": + return await self._handle_workspace_trust_decision(params) + raise NotImplementedMethodError(method) @override @@ -1875,6 +2054,90 @@ class VibeAcpAgentLoop(AcpAgent): return await self._command_reply(session, "\n".join(lines), message_id) + async def _handle_mcp( + self, session: AcpSessionLoop, text_prompt: str, message_id: str + ) -> PromptResponse: + parts = text_prompt.strip().split(None, _MCP_COMMAND_MAX_SPLITS) + subcommand = parts[1].lower() if len(parts) > 1 else "status" + arg = ( + parts[_MCP_COMMAND_ARG_INDEX].strip() + if len(parts) > _MCP_COMMAND_ARG_INDEX + else "" + ) + + match subcommand: + case "status": + return await self._handle_mcp_status(session, arg, message_id) + case "login": + return await self._handle_mcp_login(session, arg, message_id) + case "logout": + return await self._handle_mcp_logout(session, arg, message_id) + case _: + return await self._command_reply( + session, + "Usage: `/mcp status`, `/mcp login <alias>`, or `/mcp logout <alias>`", + message_id, + ) + + async def _handle_mcp_status( + self, session: AcpSessionLoop, arg: str, message_id: str + ) -> PromptResponse: + if arg: + return await self._command_reply( + session, "Usage: `/mcp status`", message_id + ) + await session.agent_loop.wait_until_ready() + statuses = session.agent_loop.mcp_registry.status() + if not statuses: + return await self._command_reply( + session, "No MCP servers configured.", message_id + ) + lines = ["### MCP auth status", ""] + for alias, status in sorted(statuses.items()): + lines.append(f"- `{alias}`: `{status.value}`") + return await self._command_reply(session, "\n".join(lines), message_id) + + async def _handle_mcp_login( + self, session: AcpSessionLoop, alias: str, message_id: str + ) -> PromptResponse: + if not alias: + return await self._command_reply( + session, "Usage: `/mcp login <alias>`", message_id + ) + await session.agent_loop.wait_until_ready() + statuses = session.agent_loop.mcp_registry.status() + if alias not in statuses: + return await self._command_reply( + session, f"Unknown MCP server: `{alias}`", message_id + ) + if statuses[alias] in {AuthStatus.STATIC, AuthStatus.STDIO}: + return await self._command_reply( + session, + f"MCP server `{alias}` is not configured for OAuth.", + message_id, + ) + return await self._command_reply( + session, _mcp_tui_login_message(alias), message_id + ) + + async def _handle_mcp_logout( + self, session: AcpSessionLoop, alias: str, message_id: str + ) -> PromptResponse: + if not alias: + return await self._command_reply( + session, "Usage: `/mcp logout <alias>`", message_id + ) + await session.agent_loop.wait_until_ready() + try: + await session.agent_loop.mcp_registry.logout(alias) + await session.agent_loop.tool_manager.refresh_remote_tools_async() + await session.agent_loop.refresh_system_prompt() + except (MCPOAuthError, ValueError) as exc: + return await self._command_reply(session, str(exc), message_id) + return await self._command_reply( + session, f"MCP server `{alias}` logged out.", message_id + ) + async def _handle_compact( self, session: AcpSessionLoop, text_prompt: str, message_id: str ) -> PromptResponse: diff --git a/vibe/acp/commands/registry.py b/vibe/acp/commands/registry.py index 223c62d..ad403f4 100644 --- a/vibe/acp/commands/registry.py +++ b/vibe/acp/commands/registry.py @@ -91,6 +91,12 @@ def _build_commands() -> dict[str, AcpCommand]: description="Show path to current session log directory", handler="_handle_log", ), + "mcp": AcpCommand( + name="mcp", + description="Show MCP OAuth status, login guidance, or log out an OAuth MCP server", + handler="_handle_mcp", + input_hint="status | login <alias> | logout <alias>", + ), "teleport": AcpCommand( name="teleport", description="Teleport session to Vibe Code Web", diff --git a/vibe/acp/exceptions.py b/vibe/acp/exceptions.py index d662e7d..c42b569 100644 --- a/vibe/acp/exceptions.py +++ b/vibe/acp/exceptions.py @@ -18,6 +18,8 @@ Vibe application codes: -31004 Context too long -31005 Refusal -31006 Compaction failed + -31007 Invalid image attachment + -31008 Images not supported by the active model """ from __future__ import annotations @@ -49,6 +51,8 @@ CONVERSATION_LIMIT = -31003 CONTEXT_TOO_LONG = -31004 REFUSAL = -31005 COMPACTION_FAILED = -31006 +INVALID_IMAGE_ATTACHMENT = -31007 +IMAGES_NOT_SUPPORTED = -31008 class VibeRequestError(RequestError): @@ -181,6 +185,23 @@ class ConfigurationError(VibeRequestError): super().__init__(message=detail) +class InvalidImageAttachmentError(VibeRequestError): + code = INVALID_IMAGE_ATTACHMENT + + def __init__(self, detail: str, reason: str) -> None: + super().__init__(message=detail, data={"reason": reason}) + + +class ImagesNotSupportedError(VibeRequestError): + code = IMAGES_NOT_SUPPORTED + + def __init__(self, model: str) -> None: + super().__init__( + message=f"Model `{model}` does not support images. " + f"Switch model, or ask me to enable the support for this model." + ) + + class ConversationLimitError(VibeRequestError): code = CONVERSATION_LIMIT diff --git a/vibe/acp/image_blocks.py b/vibe/acp/image_blocks.py new file mode 100644 index 0000000..5739262 --- /dev/null +++ b/vibe/acp/image_blocks.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import base64 +import binascii +from collections.abc import Sequence +from pathlib import Path + +from acp.helpers import ContentBlock, ImageContentBlock + +from vibe.acp.exceptions import InvalidImageAttachmentError +from vibe.core.session.image_snapshot import ( + ImageSnapshotError, + extension_for_mime, + snapshot_image_bytes, +) +from vibe.core.types import MAX_IMAGE_BYTES, MAX_IMAGES_PER_MESSAGE, ImageAttachment + + +def extract_image_attachments( + blocks: Sequence[ContentBlock], *, session_dir: Path | None +) -> list[ImageAttachment]: + image_blocks = [block for block in blocks if isinstance(block, ImageContentBlock)] + if len(image_blocks) > MAX_IMAGES_PER_MESSAGE: + raise InvalidImageAttachmentError( + f"Too many images: {len(image_blocks)} > {MAX_IMAGES_PER_MESSAGE}", + reason="too_many", + ) + + return [ + _block_to_attachment(block, session_dir=session_dir) for block in image_blocks + ] + + +def _block_to_attachment( + block: ImageContentBlock, *, session_dir: Path | None +) -> ImageAttachment: + ext = extension_for_mime(block.mime_type) + if ext is None: + raise InvalidImageAttachmentError( + f"Unsupported image mime type: {block.mime_type}", reason="wrong_type" + ) + + try: + data = base64.b64decode(block.data, validate=True) + except (binascii.Error, ValueError) as e: + raise InvalidImageAttachmentError( + f"Invalid base64 image data: {e}", reason="invalid_base64" + ) from e + + if len(data) > MAX_IMAGE_BYTES: + raise InvalidImageAttachmentError( + f"Image is too large: {len(data)} > {MAX_IMAGE_BYTES}", reason="too_large" + ) + + alias = Path(block.uri).name if block.uri else f"pasted-image{ext}" + + try: + return snapshot_image_bytes( + data, alias=alias, mime_type=block.mime_type, session_dir=session_dir + ) + except ImageSnapshotError as e: + raise InvalidImageAttachmentError(str(e), reason="snapshot_failed") from e diff --git a/vibe/acp/tools/builtins/read.py b/vibe/acp/tools/builtins/read.py index 15d9683..455bf7c 100644 --- a/vibe/acp/tools/builtins/read.py +++ b/vibe/acp/tools/builtins/read.py @@ -22,6 +22,7 @@ from vibe.acp.tools.session_update import ( ) from vibe.core.tools.base import ToolError from vibe.core.tools.builtins.read import ( + DEFAULT_LINE_LIMIT, Read as CoreReadTool, ReadArgs, ReadResult, @@ -81,19 +82,48 @@ class Read( tool_call_id=event.tool_call_id, kind=resolve_kind(event.tool_name), raw_input=event.args.model_dump_json(), - locations=[ - ToolCallLocation( - path=resolved, - field_meta={ - "type": "file_range", - "offset": event.args.offset, - "limit": event.args.limit, - }, - ) - ], + locations=[cls._call_location(resolved, event.args)], field_meta={"tool_name": event.tool_name}, ) + @staticmethod + def _call_location(resolved: str, args: ReadArgs) -> ToolCallLocation: + # Only range explicitly bounded reads; a whole-file read's default + # limit would render a misleading "L1-L<default>" chip. + if args.limit != DEFAULT_LINE_LIMIT: + return ToolCallLocation( + path=resolved, + field_meta={ + "type": "file_range", + "offset": args.offset, + "limit": args.limit, + }, + ) + return ToolCallLocation( + path=resolved, line=args.offset, field_meta={"type": "file"} + ) + + @staticmethod + def _result_location(resolved: str, result: ReadResult) -> ToolCallLocation: + # Mirror the start-event logic: a whole-file read points at the file with + # no line (requested_offset is None), so it renders "Read foo.ts" rather + # than a stray "L1". A default-limit read that got truncated only read + # part of the file, so surface the real range instead of implying the + # whole file was read. num_lines is already clamped to the lines read. + bounded = result.requested_limit != DEFAULT_LINE_LIMIT or result.was_truncated + if bounded: + return ToolCallLocation( + path=resolved, + field_meta={ + "type": "file_range", + "offset": result.start_line, + "limit": result.num_lines, + }, + ) + return ToolCallLocation( + path=resolved, line=result.requested_offset, field_meta={"type": "file"} + ) + @classmethod def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None: if failure := failed_tool_result(event, ReadResult): @@ -102,16 +132,7 @@ class Read( result = event.result assert isinstance(result, ReadResult) resolved = str(Path(result.file_path).resolve()) - locations = [ - ToolCallLocation( - path=resolved, - field_meta={ - "type": "file_range", - "offset": result.start_line, - "limit": result.num_lines, - }, - ) - ] + locations = [cls._result_location(resolved, result)] return ToolCallProgress( session_update="tool_call_update", diff --git a/vibe/acp/tools/session_update.py b/vibe/acp/tools/session_update.py index 3087e96..2bc625a 100644 --- a/vibe/acp/tools/session_update.py +++ b/vibe/acp/tools/session_update.py @@ -8,6 +8,7 @@ from acp.schema import ( TextContentBlock, ToolCallProgress, ToolCallStart, + ToolCallStatus, ToolKind, ) from pydantic import BaseModel @@ -99,7 +100,18 @@ def fallback_tool_call(event: ToolCallEvent, title: str) -> ToolCallStart: ) -def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None: +def tool_call_session_update( + event: ToolCallEvent, status: ToolCallStatus | None = None +) -> SessionUpdate | None: + update = _build_tool_call_start(event) + # Status is a lifecycle concern owned by the caller (live start -> pending, + # replay -> completed); the per-tool builders leave it unset. + if isinstance(update, ToolCallStart) and update.status is None: + update.status = status + return update + + +def _build_tool_call_start(event: ToolCallEvent) -> SessionUpdate | None: if issubclass(event.tool_class, ToolCallSessionUpdateProtocol): return event.tool_class.tool_call_session_update(event) diff --git a/vibe/acp/user_display_content.py b/vibe/acp/user_display_content.py new file mode 100644 index 0000000..dc2547c --- /dev/null +++ b/vibe/acp/user_display_content.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from vibe.core.types import UserDisplayContentMetadata + +USER_DISPLAY_CONTENT_META_KEY = "user_display_content" + + +def parse_user_display_content_metadata( + value: object, +) -> UserDisplayContentMetadata | None: + if value is None: + return None + + return UserDisplayContentMetadata.model_validate(value) diff --git a/vibe/acp/utils.py b/vibe/acp/utils.py index ffabbb4..536809d 100644 --- a/vibe/acp/utils.py +++ b/vibe/acp/utils.py @@ -3,6 +3,7 @@ from __future__ import annotations from enum import StrEnum from typing import TYPE_CHECKING +from acp.helpers import SessionUpdate from acp.schema import ( AgentMessageChunk, AgentThoughtChunk, @@ -22,11 +23,20 @@ from acp.schema import ( UserMessageChunk, ) +from vibe.acp.tools.session_update import resolve_kind, tool_call_session_update +from vibe.acp.user_display_content import USER_DISPLAY_CONTENT_META_KEY from vibe.core.agents.models import AgentProfile, AgentType from vibe.core.config._settings import THINKING_LEVELS, ThinkingLevel +from vibe.core.llm.format import ResolvedToolCall 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.types import ( + CompactEndEvent, + CompactStartEvent, + LLMMessage, + ToolCall, + ToolCallEvent, +) from vibe.core.utils import compact_complete_display if TYPE_CHECKING: @@ -280,10 +290,20 @@ def get_proxy_help_text() -> str: def create_user_message_replay(msg: LLMMessage) -> UserMessageChunk: content = msg.content if isinstance(msg.content, str) else "" + field_meta = ( + { + USER_DISPLAY_CONTENT_META_KEY: msg.user_display_content.model_dump( + mode="json" + ) + } + if msg.user_display_content is not None + else None + ) return UserMessageChunk( session_update="user_message_chunk", content=TextContentBlock(type="text", text=content), message_id=msg.message_id, + field_meta=field_meta, ) @@ -313,26 +333,54 @@ def create_reasoning_replay(msg: LLMMessage) -> AgentThoughtChunk | None: def create_tool_call_replay( tool_call_id: str, tool_name: str, arguments: str | None ) -> ToolCallStart: + # Fallback when a stored tool call can't be resolved (unknown tool or + # args that no longer validate). Replayed calls are historical, so they + # carry a terminal status; the host drops created events without one. return ToolCallStart( session_update="tool_call", title=tool_name, tool_call_id=tool_call_id, - kind="other", + kind=resolve_kind(tool_name), + status="completed", raw_input=arguments, field_meta={"tool_name": tool_name}, ) +def tool_call_replay_update( + resolved: ResolvedToolCall | None, tool_call: ToolCall +) -> SessionUpdate | None: + tool_name = tool_call.function.name or "" + tool_call_id = tool_call.id or "" + if resolved is None: + return create_tool_call_replay( + tool_call_id, tool_name, tool_call.function.arguments + ) + + return tool_call_session_update( + ToolCallEvent( + tool_call_id=resolved.call_id, + tool_name=resolved.tool_name, + tool_class=resolved.tool_class, + args=resolved.validated_args, + ), + status="completed", + ) + + def create_tool_result_replay(msg: LLMMessage) -> ToolCallProgress | None: if not msg.tool_call_id: return None content = msg.content if isinstance(msg.content, str) else "" + tool_name = msg.name or "" return ToolCallProgress( session_update="tool_call_update", tool_call_id=msg.tool_call_id, status="completed", + kind=resolve_kind(tool_name), raw_output=content, + field_meta={"tool_name": tool_name}, content=[ ContentToolCallContent( type="content", content=TextContentBlock(type="text", text=content) diff --git a/vibe/cli/autocompletion/base.py b/vibe/cli/autocompletion/base.py index edc562a..06e88bb 100644 --- a/vibe/cli/autocompletion/base.py +++ b/vibe/cli/autocompletion/base.py @@ -18,5 +18,5 @@ class CompletionView(Protocol): def clear_completion_suggestions(self) -> None: ... def replace_completion_range( - self, start: int, end: int, replacement: str + self, start: int, end: int, replacement: str, *, suppress_update: bool = False ) -> None: ... diff --git a/vibe/cli/autocompletion/slash_command.py b/vibe/cli/autocompletion/slash_command.py index 45bc723..4f23a62 100644 --- a/vibe/cli/autocompletion/slash_command.py +++ b/vibe/cli/autocompletion/slash_command.py @@ -90,6 +90,6 @@ class SlashCommandController: return False start, end = replacement_range - self._view.replace_completion_range(start, end, alias) + self._view.replace_completion_range(start, end, alias, suppress_update=True) self.reset() return True diff --git a/vibe/cli/cache.py b/vibe/cli/cache.py deleted file mode 100644 index 66a5cb4..0000000 --- a/vibe/cli/cache.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -import logging -from pathlib import Path -import tomllib -from typing import Any - -import tomli_w - -logger = logging.getLogger(__name__) - - -def read_cache(cache_path: Path) -> dict[str, Any]: - """Read the cache.toml file, returning an empty dict on any error.""" - try: - with cache_path.open("rb") as f: - return tomllib.load(f) - except (OSError, tomllib.TOMLDecodeError): - return {} - - -def write_cache(cache_path: Path, section: str, data: dict[str, Any]) -> None: - """Write the full cache dict to cache.toml, merging with existing data.""" - existing = read_cache(cache_path) - existing.setdefault(section, {}) - existing[section].update(data) - try: - cache_path.parent.mkdir(parents=True, exist_ok=True) - with cache_path.open("wb") as f: - tomli_w.dump(existing, f) - except OSError: - logger.debug("Failed to write cache file %s", cache_path, exc_info=True) diff --git a/vibe/cli/cli.py b/vibe/cli/cli.py index c771252..f0fa634 100644 --- a/vibe/cli/cli.py +++ b/vibe/cli/cli.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse import asyncio +from collections.abc import Callable from pathlib import Path import sys @@ -15,12 +16,17 @@ from vibe.cli.terminal_detect import detect_terminal from vibe.cli.textual_ui.app import StartupOptions, run_textual_ui from vibe.cli.update_notifier import ( FileSystemUpdateCacheRepository, + PyPIUpdateGateway, UpdateCacheRepository, + UpdateError, + UpdateGateway, get_pending_update_from_cache, + get_update_if_available, mark_update_as_dismissed, ) from vibe.core.agent_loop import AgentLoop, TeleportError from vibe.core.agents.models import BuiltinAgentName +from vibe.core.cache_store import FileSystemVibeCodeCacheStore from vibe.core.config import MissingAPIKeyError, VibeConfig, load_dotenv_values from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.hooks.config import HookConfigResult, load_hooks_from_fs @@ -36,7 +42,12 @@ from vibe.core.trusted_folders import find_trustable_files, trusted_folders_mana from vibe.core.types import LLMMessage, OutputFormat, Role from vibe.core.utils import ConversationLimitException from vibe.setup.onboarding import run_onboarding -from vibe.setup.update_prompt import UpdatePromptResult, ask_update_prompt +from vibe.setup.update_prompt import ( + UpdatePromptMode, + UpdatePromptResult, + ask_update_prompt, + load_update_prompt_theme, +) def _build_cli_entrypoint_metadata() -> EntrypointMetadata: @@ -256,31 +267,25 @@ def _run_programmatic_mode( sys.exit(1) -def _maybe_run_startup_update_prompt( - config: VibeConfig, repository: UpdateCacheRepository +def _show_update_prompt( + repository: UpdateCacheRepository, + latest_version: str, + *, + theme: str | None, + dismiss_on_continue: bool, + prompt_mode: UpdatePromptMode, ) -> None: - if not config.enable_update_checks: - return - - try: - latest_version = asyncio.run( - get_pending_update_from_cache(repository, __version__) - ) - except OSError as exc: - logger.debug("Failed to read pending update from cache", exc_info=exc) - return - - if latest_version is None: - return - - result = ask_update_prompt(__version__, latest_version, theme=config.theme) + result = ask_update_prompt( + __version__, latest_version, theme=theme, prompt_mode=prompt_mode + ) match result: case UpdatePromptResult.CONTINUE: - try: - asyncio.run(mark_update_as_dismissed(repository, latest_version)) - except OSError as exc: - logger.debug("Failed to persist dismissed update", exc_info=exc) + if dismiss_on_continue: + try: + asyncio.run(mark_update_as_dismissed(repository, latest_version)) + except OSError as exc: + logger.debug("Failed to persist dismissed update", exc_info=exc) return case UpdatePromptResult.QUIT: sys.exit(0) @@ -301,7 +306,74 @@ def _maybe_run_startup_update_prompt( sys.exit(1) -def run_cli(args: argparse.Namespace) -> None: +def _maybe_run_startup_update_prompt( + config: VibeConfig, repository: UpdateCacheRepository +) -> None: + if not config.enable_update_checks: + return + + try: + latest_version = asyncio.run( + get_pending_update_from_cache(repository, __version__) + ) + except OSError as exc: + logger.debug("Failed to read pending update from cache", exc_info=exc) + return + + if latest_version is None: + return + + _show_update_prompt( + repository, + latest_version, + theme=config.theme, + dismiss_on_continue=True, + prompt_mode=UpdatePromptMode.STARTUP, + ) + + +def _run_check_upgrade( + repository: UpdateCacheRepository, + *, + update_notifier: UpdateGateway | None = None, + theme: str | None = None, +) -> None: + notifier = update_notifier or PyPIUpdateGateway(project_name="mistral-vibe") + try: + update = asyncio.run( + get_update_if_available( + update_notifier=notifier, + current_version=__version__, + update_cache_repository=repository, + force_check=True, + ) + ) + except UpdateError as exc: + rprint(f"[red]✗ Update check failed:[/] {exc.message}") + sys.exit(1) + except OSError as exc: + logger.debug("Failed to persist forced update check", exc_info=exc) + rprint("[red]✗ Update check failed while writing the update cache.[/]") + sys.exit(1) + + if update is None: + rprint(f"[green]Vibe is already up to date ({__version__}).[/]") + return + + _show_update_prompt( + repository, + update.latest_version, + theme=theme, + dismiss_on_continue=False, + prompt_mode=UpdatePromptMode.CHECK_UPGRADE, + ) + + +def run_cli( + args: argparse.Namespace, + *, + resolve_trusted_folder: Callable[[], None] | None = None, +) -> None: load_dotenv_values() bootstrap_config_files() @@ -310,12 +382,21 @@ def run_cli(args: argparse.Namespace) -> None: sys.exit(0) try: + update_cache_repository = FileSystemUpdateCacheRepository() + if getattr(args, "check_upgrade", False): + _run_check_upgrade( + update_cache_repository, theme=load_update_prompt_theme() + ) + sys.exit(0) + is_interactive = args.prompt is None config = load_config_or_exit(interactive=is_interactive) - update_cache_repository = FileSystemUpdateCacheRepository() if is_interactive: _maybe_run_startup_update_prompt(config, update_cache_repository) + if resolve_trusted_folder is not None: + resolve_trusted_folder() + config = load_config_or_exit(interactive=True) initial_agent_name = get_initial_agent_name(args, config) hook_config_result = load_hooks_from_fs(config) @@ -337,6 +418,7 @@ def run_cli(args: argparse.Namespace) -> None: terminal_emulator=detect_terminal(), defer_heavy_init=True, hook_config_result=hook_config_result, + cache_store=FileSystemVibeCodeCacheStore(), ) except ValueError as e: rprint(f"[red]Error:[/] {e}") diff --git a/vibe/cli/commands.py b/vibe/cli/commands.py index 2567eaf..a3cfeb5 100644 --- a/vibe/cli/commands.py +++ b/vibe/cli/commands.py @@ -137,7 +137,8 @@ class CommandRegistry: aliases=frozenset(["/mcp", "/connectors"]), description=( "Display available MCP servers and connectors. " - "Pass a name to list its tools" + "Pass a name to list tools; subcommands: status, " + "login <alias>, logout <alias>" ), handler="_show_mcp", ), diff --git a/vibe/cli/entrypoint.py b/vibe/cli/entrypoint.py index c7bd2e1..99d64f4 100644 --- a/vibe/cli/entrypoint.py +++ b/vibe/cli/entrypoint.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +from collections.abc import Callable import os from pathlib import Path import sys @@ -50,7 +51,7 @@ def parse_arguments() -> argparse.Namespace: metavar="TEXT", help="Run in programmatic mode: send prompt, output response, and exit. " "Tool approval follows the selected --agent (or 'default_agent' config); " - "pass --auto-approve to allow all tool calls.", + "pass --auto-approve or --yolo to allow all tool calls.", ) parser.add_argument( "--max-turns", @@ -104,11 +105,17 @@ def parse_arguments() -> argparse.Namespace: ) agent_group.add_argument( "--auto-approve", + "--yolo", action="store_true", help="Shortcut for --agent auto-approve. Approves all tool calls without " "prompting.", ) parser.add_argument("--setup", action="store_true", help="Setup API key and exit") + parser.add_argument( + "--check-upgrade", + action="store_true", + help="Check for a Vibe update now, prompt to install it, and exit", + ) parser.add_argument( "--workdir", type=Path, @@ -216,14 +223,19 @@ def main() -> None: additional_dirs.append(resolved) trusted_folders_manager.trust_for_session(resolved) - is_interactive = args.prompt is None - if is_interactive: - check_and_resolve_trusted_folder(cwd) init_harness_files_manager("user", "project", additional_dirs=additional_dirs) from vibe.cli.cli import run_cli - run_cli(args) + resolve_trusted_folder: Callable[[], None] | None = None + if args.prompt is None and not args.check_upgrade: + + def _resolve_trusted_folder() -> None: + check_and_resolve_trusted_folder(cwd) + + resolve_trusted_folder = _resolve_trusted_folder + + run_cli(args, resolve_trusted_folder=resolve_trusted_folder) if __name__ == "__main__": diff --git a/vibe/cli/plan_offer/decide_plan_offer.py b/vibe/cli/plan_offer/decide_plan_offer.py index 48b84bf..caaed7c 100644 --- a/vibe/cli/plan_offer/decide_plan_offer.py +++ b/vibe/cli/plan_offer/decide_plan_offer.py @@ -2,7 +2,6 @@ from __future__ import annotations from enum import StrEnum import logging -from os import getenv from vibe.cli.plan_offer.ports.whoami_gateway import ( WhoAmIGateway, @@ -15,6 +14,7 @@ from vibe.core.config import ( DEFAULT_MISTRAL_API_ENV_KEY, DEFAULT_VIBE_BASE_URL, ProviderConfig, + resolve_api_key, ) from vibe.core.types import Backend @@ -110,7 +110,7 @@ def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None: if provider.backend == Backend.MISTRAL: api_env_key = provider.api_key_env_var - return getenv(api_env_key) + return resolve_api_key(api_env_key) def plan_offer_cta( diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index fe50c48..bbcea3a 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -10,10 +10,12 @@ import gc import os from pathlib import Path import signal +import sys import time from typing import Any, ClassVar, assert_never, cast from uuid import uuid4 from weakref import WeakKeyDictionary +import webbrowser from pydantic import BaseModel from rich import print as rprint @@ -53,7 +55,6 @@ from vibe.cli.textual_ui.notifications import ( TextualNotificationAdapter, ) from vibe.cli.textual_ui.quit_manager import QuitManager -from vibe.cli.textual_ui.remote import RemoteSessionManager, is_progress_event from vibe.cli.textual_ui.scheduled_loop_runner import ScheduledLoopRunner from vibe.cli.textual_ui.session_exit import print_session_resume_message from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp @@ -151,6 +152,7 @@ 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_recorder import AudioRecorder +from vibe.core.auth import MCPOAuthError from vibe.core.autocompletion.path_prompt import ( PathPromptPayload, PathResource, @@ -170,10 +172,7 @@ from vibe.core.paths import HISTORY_FILE from vibe.core.rewind import RewindError from vibe.core.session.image_snapshot import ImageSnapshotError, snapshot_image from vibe.core.session.resume_sessions import ( - RemoteResumeResult, - RemoteResumeSessions, ResumeSessionInfo, - can_delete_resume_session_source, list_local_resume_sessions, session_latest_messages, short_session_id, @@ -201,6 +200,7 @@ from vibe.core.tools.builtins.ask_user_question import ( Question, ) from vibe.core.tools.connectors import compute_connector_counts +from vibe.core.tools.mcp import AuthStatus from vibe.core.tools.mcp_settings import persist_mcp_toggle from vibe.core.tools.permissions import RequiredPermission from vibe.core.transcribe import make_transcribe_client @@ -209,13 +209,17 @@ from vibe.core.types import ( MAX_IMAGES_PER_MESSAGE, AgentStats, ApprovalResponse, + AssistantEvent, BaseEvent, ContextTooLongError, ImageAttachment, LLMMessage, RateLimitError, + ReasoningEvent, RefusalError, Role, + ToolCallEvent, + ToolStreamEvent, WaitingForInputEvent, ) from vibe.core.utils import ( @@ -227,6 +231,12 @@ from vibe.core.utils import ( _VSCODE_FAMILY_TERMINALS = {Terminal.VSCODE, Terminal.VSCODE_INSIDERS, Terminal.CURSOR} +def is_progress_event(event: object) -> bool: + return isinstance( + event, (AssistantEvent, ReasoningEvent, ToolCallEvent, ToolStreamEvent) + ) + + def _is_vscode_family_terminal() -> bool: return detect_terminal() in _VSCODE_FAMILY_TERMINALS @@ -429,9 +439,6 @@ class VibeApp(App): # noqa: PLR0904 self._agent_task: asyncio.Task | None = None self._bash_task: asyncio.Task | None = None self._queue = QueueController(self._build_queue_ports()) - self._remote_manager = RemoteSessionManager() - self._remote_resume = RemoteResumeSessions(lambda: self.config) - self._resume_merge_task: asyncio.Task[None] | None = None self._loading_widget: LoadingWidget | None = None self._pending_approval: asyncio.Future | None = None @@ -515,8 +522,6 @@ class VibeApp(App): # noqa: PLR0904 agent_running=lambda: self._agent_running, bash_task=lambda: self._bash_task, active_model=self._active_model_or_none, - remote_is_active=lambda: self._remote_manager.is_active, - remote_stop_stream=lambda: self._remote_manager.stop_stream(), remove_loading_widget=self._remove_loading_widget, set_loading_queue_count=self._set_loading_queue_count, inject_user_context=self.agent_loop.inject_user_context, @@ -524,7 +529,6 @@ class VibeApp(App): # noqa: PLR0904 start_agent_turn=self._start_queued_agent_turn, await_agent_turn=self._await_agent_turn, run_bash=self._start_queued_bash, - handle_user_message=self._handle_user_message, maybe_show_feedback_bar=self._maybe_show_feedback_bar, send_skill_telemetry=self._send_skill_telemetry, send_at_mention_telemetry=self._send_at_mention_telemetry, @@ -546,7 +550,7 @@ class VibeApp(App): # noqa: PLR0904 def _maybe_show_feedback_bar(self) -> None: if self._feedback_bar_manager.should_show(self.agent_loop): self._feedback_bar.show() - self._feedback_bar_manager.record_feedback_asked() + self._feedback_bar_manager.record_feedback_asked(self.agent_loop) def _start_queued_agent_turn( self, @@ -668,7 +672,6 @@ class VibeApp(App): # noqa: PLR0904 mount_callback=self._mount_and_scroll, get_tools_collapsed=lambda: self._tools_collapsed, on_profile_changed=self._on_profile_changed, - is_remote=self._remote_manager.is_active, ) self._chat_input_container = self.query_one(ChatInputContainer) @@ -734,6 +737,7 @@ class VibeApp(App): # noqa: PLR0904 await self._ensure_loading_widget("Initializing", show_hint=False) init_widget = self._loading_widget await self.agent_loop.wait_until_ready() + await self._show_mcp_auth_required_notice() except Exception as e: await self._mount_and_scroll( ErrorMessage( @@ -757,6 +761,29 @@ class VibeApp(App): # noqa: PLR0904 except Exception: pass + async def _show_mcp_auth_required_notice(self) -> None: + statuses = self.agent_loop.mcp_registry.status() + aliases = sorted( + alias + for alias, status in statuses.items() + if status is AuthStatus.NEEDS_AUTH + ) + if not aliases: + return + command = f"/mcp login {aliases[0]}" + if len(aliases) > 1: + detail = ", ".join(aliases) + message = ( + "MCP servers need OAuth authentication: " + f"{detail}. Run `{command}` to start with {aliases[0]!r}." + ) + else: + message = ( + f"MCP server {aliases[0]!r} needs OAuth authentication. " + f"Run `{command}` to authenticate." + ) + await self._mount_and_scroll(UserCommandMessage(message)) + def _process_initial_prompt(self) -> None: if self._teleport_on_start and self.commands.has_command("teleport"): self.run_worker( @@ -926,21 +953,11 @@ class VibeApp(App): # noqa: PLR0904 await self._remove_loading_widget() async def on_question_app_answered(self, message: QuestionApp.Answered) -> None: - if self._remote_manager.has_pending_input and self._remote_manager.is_active: - result = AskUserQuestionResult(answers=message.answers, cancelled=False) - await self._handle_remote_answer(result) - return - if self._pending_question and not self._pending_question.done(): result = AskUserQuestionResult(answers=message.answers, cancelled=False) self._pending_question.set_result(result) async def on_question_app_cancelled(self, message: QuestionApp.Cancelled) -> None: - if self._remote_manager.has_pending_input: - self._remote_manager.cancel_pending_input() - await self._switch_to_input_app() - return - if self._pending_question and not self._pending_question.done(): result = AskUserQuestionResult(answers=[], cancelled=True) self._pending_question.set_result(result) @@ -1544,10 +1561,6 @@ class VibeApp(App): # noqa: PLR0904 async def _handle_user_message( self, message: str, *, title_source: str | None = None ) -> None: - if self._remote_manager.is_active: - await self._handle_remote_user_message(message) - return - prompt_payload = build_path_prompt_payload(message, base_dir=Path.cwd()) images = await self._prepare_images_or_abort(prompt_payload) if images is None: @@ -1572,10 +1585,9 @@ class VibeApp(App): # noqa: PLR0904 await self._mount_and_scroll(user_message) if self._feedback_bar_manager.should_show(self.agent_loop): self._feedback_bar.show() - self._feedback_bar_manager.record_feedback_asked() + self._feedback_bar_manager.record_feedback_asked(self.agent_loop) if not self._agent_running: - await self._remote_manager.stop_stream() await self._remove_loading_widget() self._agent_task = asyncio.create_task( self._handle_agent_loop_turn( @@ -1587,40 +1599,6 @@ class VibeApp(App): # noqa: PLR0904 ) self._queue.notify_busy_changed() - async def _handle_remote_user_message(self, message: str) -> None: - warning = self._remote_manager.validate_input() - if warning: - await self._mount_and_scroll(WarningMessage(warning)) - return - try: - await self._remote_manager.send_prompt(message) - except Exception as e: - await self._mount_and_scroll( - ErrorMessage( - f"Failed to send message: {e}", collapsed=self._tools_collapsed - ) - ) - return - await self._ensure_loading_widget() - - async def _handle_remote_waiting_input(self, event: WaitingForInputEvent) -> None: - self._remote_manager.set_pending_input(event) - if question_args := self._remote_manager.build_question_args(event): - await self._switch_to_question_app(question_args) - return - await self._switch_to_input_app() - - async def _handle_remote_answer(self, result: AskUserQuestionResult) -> None: - if result.cancelled or not result.answers: - self._remote_manager.cancel_pending_input() - await self._switch_to_input_app() - return - await self._remote_manager.send_prompt( - result.answers[0].answer, require_source=False - ) - await self._switch_to_input_app() - await self._ensure_loading_widget() - def _reset_ui_state(self) -> None: self._windowing.reset() self._tool_call_map = None @@ -1772,8 +1750,6 @@ class VibeApp(App): # noqa: PLR0904 self._narrator_manager.on_turn_event(event) if isinstance(event, WaitingForInputEvent): await self._remove_loading_widget() - if self._remote_manager.is_active: - await self._handle_remote_waiting_input(event) elif isinstance(event, HookStartEvent): await self._ensure_loading_widget(f"Running hook {event.hook_name}") elif self._loading_widget is None and is_progress_event(event): @@ -1937,22 +1913,6 @@ class VibeApp(App): # noqa: PLR0904 teleport_msg = TeleportMessage() await self._mount_and_scroll(teleport_msg) - if self._remote_manager.is_active: - send_teleport_early_failure_telemetry( - self.agent_loop.telemetry_client, - stage="remote_session", - error_class="TeleportRemoteSessionError", - nb_session_messages=len(self.agent_loop.messages[1:]), - ) - await loading.remove() - await self._mount_and_scroll( - ErrorMessage( - "Teleport is not available for remote sessions.", - collapsed=self._tools_collapsed, - ) - ) - return - try: gen = self.agent_loop.teleport_to_vibe_code(prompt) async for event in gen: @@ -2083,7 +2043,92 @@ class VibeApp(App): # noqa: PLR0904 self._refresh_banner() return "Refreshed." + async def _maybe_handle_mcp_subcommand(self, cmd_args: str) -> bool: + parts = cmd_args.strip().split(None, 1) + if not parts or parts[0] not in {"login", "logout", "status"}: + return False + + subcommand = parts[0] + arg = parts[1].strip() if len(parts) > 1 else "" + match subcommand: + case "status": + if arg: + await self._mount_and_scroll( + ErrorMessage("Usage: /mcp status", collapsed=True) + ) + return True + await self._show_mcp_status() + case "login": + await self._mcp_login(arg) + case "logout": + await self._mcp_logout(arg) + return True + + async def _show_mcp_status(self) -> None: + await self.agent_loop.wait_until_ready() + statuses = self.agent_loop.mcp_registry.status() + if not statuses: + await self._mount_and_scroll( + UserCommandMessage("No MCP servers configured.") + ) + return + lines = ["### MCP auth status", ""] + for alias, status in sorted(statuses.items()): + lines.append(f"- `{alias}`: `{status.value}`") + await self._mount_and_scroll(UserCommandMessage("\n".join(lines))) + + async def _mcp_login(self, alias: str) -> None: + if not alias: + await self._mount_and_scroll( + ErrorMessage("Usage: /mcp login <alias>", collapsed=True) + ) + return + + await self.agent_loop.wait_until_ready() + + async def on_url(url: str) -> None: + await self._mount_and_scroll( + UserCommandMessage(f"Open this URL in your browser:\n\n {url}") + ) + try: + webbrowser.open(url) + except Exception as exc: + logger.debug("Failed to open MCP OAuth URL in browser: %s", exc) + + try: + await self.agent_loop.mcp_registry.login(alias, on_url=on_url) + await self._refresh_mcp_browser() + except (MCPOAuthError, ValueError) as exc: + await self._mount_and_scroll(ErrorMessage(str(exc), collapsed=True)) + return + + await self._mount_and_scroll( + UserCommandMessage(f"MCP server `{alias}` authenticated.") + ) + + async def _mcp_logout(self, alias: str) -> None: + if not alias: + await self._mount_and_scroll( + ErrorMessage("Usage: /mcp logout <alias>", collapsed=True) + ) + return + + await self.agent_loop.wait_until_ready() + try: + await self.agent_loop.mcp_registry.logout(alias) + await self._refresh_mcp_browser() + except (MCPOAuthError, ValueError) as exc: + await self._mount_and_scroll(ErrorMessage(str(exc), collapsed=True)) + return + + await self._mount_and_scroll( + UserCommandMessage(f"MCP server `{alias}` logged out.") + ) + async def _show_mcp(self, cmd_args: str = "", **kwargs: Any) -> None: + if await self._maybe_handle_mcp_subcommand(cmd_args): + return + mcp_servers = self.config.mcp_servers connector_registry = ( self.agent_loop.connector_registry if self._connectors_enabled else None @@ -2190,15 +2235,6 @@ class VibeApp(App): # noqa: PLR0904 return renamed_title async def _rename_session(self, cmd_args: str = "", **kwargs: Any) -> None: - if self._remote_manager.is_active: - await self._mount_and_scroll( - ErrorMessage( - "Renaming is only supported for local sessions.", - collapsed=self._tools_collapsed, - ) - ) - return - title = cmd_args.strip() if not title: await self._mount_and_scroll( @@ -2229,96 +2265,26 @@ class VibeApp(App): # noqa: PLR0904 cwd=str(Path.cwd()), ) - async def _merge_remote_into_picker( - self, picker: SessionPickerApp, remote_task: asyncio.Task[RemoteResumeResult] - ) -> None: - remote_sessions, remote_error = await remote_task - if not picker.is_mounted: - return - if remote_error is not None: - await self._mount_and_scroll( - ErrorMessage(remote_error, collapsed=self._tools_collapsed) - ) - if remote_sessions: - picker.add_sessions( - remote_sessions, session_latest_messages(remote_sessions, self.config) - ) - - async def _cancel_resume_merge(self) -> None: - """Cancel the task that fetch and merges remote sessions into the picker, if it exists.""" - if self._resume_merge_task is not None and not self._resume_merge_task.done(): - self._resume_merge_task.cancel() - with suppress(asyncio.CancelledError): - await self._resume_merge_task - self._resume_merge_task = None - - async def _close_remote_resume(self) -> None: - """Close the remote resume connection and cancel any ongoing merge task. - Used to gracefully close the connection when the app is exiting - """ - await self._cancel_resume_merge() - await self._remote_resume.aclose() - async def _show_session_picker(self, **kwargs: Any) -> None: - await self._cancel_resume_merge() - remote_list_timeout = max(float(self.config.api_timeout), 10.0) - remote_task = self._remote_resume.start(remote_list_timeout) - - # If there are no local sessions, show the remote picker directly if not self.config.session_logging.enabled or not ( local_sessions := list_local_resume_sessions(self.config, str(Path.cwd())) ): - await self._show_session_picker_remote_only(remote_task) - return - - picker = self._build_picker(local_sessions) - await self._switch_from_input(picker) - self._resume_merge_task = asyncio.create_task( - self._merge_remote_into_picker(picker, remote_task) - ) - - async def _show_session_picker_remote_only( - self, remote_task: asyncio.Task[RemoteResumeResult] - ) -> None: - await self._ensure_loading_widget("Loading sessions") - try: - remote_sessions, remote_error = await remote_task - except asyncio.CancelledError: - return - finally: - await self._remove_loading_widget() - - if remote_error is not None: - await self._mount_and_scroll( - ErrorMessage(remote_error, collapsed=self._tools_collapsed) - ) - - if not remote_sessions: await self._mount_and_scroll( UserCommandMessage("No sessions found for this directory.") ) return - await self._switch_from_input(self._build_picker(remote_sessions)) + await self._switch_from_input(self._build_picker(local_sessions)) async def on_session_picker_app_session_selected( self, event: SessionPickerApp.SessionSelected ) -> None: await self._switch_to_input_app() session = ResumeSessionInfo( - session_id=event.session_id, - source=event.source, - cwd="", - title=None, - end_time=None, + session_id=event.session_id, cwd="", title=None, end_time=None ) try: - if event.source == "local": - await self._resume_local_session(session) - elif event.source == "remote": - await self._resume_remote_session(session) - else: - raise ValueError(f"Unknown session source: {event.source}") + await self._resume_local_session(session) except Exception as e: await self._mount_and_scroll( ErrorMessage( @@ -2329,17 +2295,7 @@ class VibeApp(App): # noqa: PLR0904 async def on_session_picker_app_session_delete_requested( self, event: SessionPickerApp.SessionDeleteRequested ) -> None: - if not can_delete_resume_session_source(event.source): - self._clear_pending_session_delete(event.option_id) - await self._mount_and_scroll( - ErrorMessage( - "Deleting remote sessions is not supported.", - collapsed=self._tools_collapsed, - ) - ) - return - - if event.source == "local" and event.session_id == self.agent_loop.session_id: + if event.session_id == self.agent_loop.session_id: self._clear_pending_session_delete(event.option_id) await self._mount_and_scroll( ErrorMessage( @@ -2394,7 +2350,6 @@ class VibeApp(App): # noqa: PLR0904 await self._mount_and_scroll(UserCommandMessage("Resume cancelled.")) async def _resume_local_session(self, session: ResumeSessionInfo) -> None: - await self._remote_manager.detach() session_config = self.config.session_logging session_path = SessionLoader.find_session_by_id( session.session_id, session_config @@ -2432,8 +2387,6 @@ class VibeApp(App): # noqa: PLR0904 await self._messages_area.remove_children() - if self.event_handler: - self.event_handler.is_remote = False await self._resume_history_from_messages() self._loop_runner.restore_from_session() await self._mount_and_scroll( @@ -2442,68 +2395,6 @@ class VibeApp(App): # noqa: PLR0904 ) ) - async def _resume_remote_session(self, session: ResumeSessionInfo) -> None: - self.agent_loop.telemetry_client.send_remote_resume_requested( - session_id=session.session_id - ) - await self._remote_manager.attach( - session_id=session.session_id, config=self.config - ) - self._emit_session_closed_for_active_session() - self.agent_loop.session_id = session.session_id - self._refresh_profile_widgets() - if self._chat_input_container: - self._chat_input_container.set_custom_border(None) - - self._reset_ui_state() - await self._load_more.hide() - - await self._messages_area.remove_children() - - if self.event_handler: - self.event_handler.is_remote = True - self._remote_manager.start_stream(self) - - async def on_remote_event(self, event: BaseEvent, loading_widget: Any) -> None: - if self.event_handler: - await self.event_handler.handle_event(event, loading_widget=loading_widget) - - async def on_remote_waiting_input(self, event: WaitingForInputEvent) -> None: - await self._handle_remote_waiting_input(event) - - async def on_remote_user_message_cleared_input(self) -> None: - await self._switch_to_input_app() - - async def on_remote_stream_error(self, error: str) -> None: - await self._mount_and_scroll( - ErrorMessage(error, collapsed=self._tools_collapsed) - ) - - async def on_remote_stream_ended(self, msg_type: str, text: str) -> None: - if msg_type == "error": - widget = ErrorMessage(text, collapsed=self._tools_collapsed) - elif msg_type == "warning": - widget = WarningMessage(text) - else: - widget = UserCommandMessage(text) - await self._mount_and_scroll(widget) - if self._chat_input_container: - self._chat_input_container.set_custom_border("Remote session ended") - - async def on_remote_finalize_streaming(self) -> None: - if self.event_handler: - await self.event_handler.finalize_streaming() - - async def remove_loading(self) -> None: - await self._remove_loading_widget() - - async def ensure_loading(self, status: str = DEFAULT_LOADING_STATUS) -> None: - await self._ensure_loading_widget(status) - - @property - def loading_widget(self) -> LoadingWidget | None: - return self._loading_widget - async def _reload_config(self, **kwargs: Any) -> None: try: self._reset_ui_state() @@ -2579,11 +2470,6 @@ class VibeApp(App): # noqa: PLR0904 async def _clear_history(self, **kwargs: Any) -> None: try: self._reset_ui_state() - if self._remote_manager.is_active: - await self._remote_manager.detach() - self._refresh_profile_widgets() - if self.event_handler: - self.event_handler.is_remote = False if self._chat_input_container: self._chat_input_container.set_custom_border(None) await self.agent_loop.clear_history() @@ -2688,8 +2574,6 @@ class VibeApp(App): # noqa: PLR0904 self.event_handler.current_compact = None def _get_session_resume_info(self) -> str | None: - if self._remote_manager.is_active: - return None if not self.agent_loop.session_logger.enabled: return None if not self.agent_loop.session_logger.session_id: @@ -2703,17 +2587,14 @@ class VibeApp(App): # noqa: PLR0904 return short_session_id(self.agent_loop.session_logger.session_id) async def _exit_app(self, **kwargs: Any) -> None: - self._emit_session_closed_for_active_session() - await self._loop_runner.stop() - self._log_reader.shutdown() - await self._voice_manager.close() - await self._narrator_manager.close() - await self._close_remote_resume() - await self.agent_loop.aclose() try: - await self.agent_loop.telemetry_client.aclose() - except Exception as exc: - logger.error("Failed to close telemetry client during exit", exc_info=exc) + self._emit_session_closed_for_active_session() + await self._begin_shutdown() + if self._agent_task and not self._agent_task.done(): + self._agent_task.cancel() + if self._bash_task and not self._bash_task.done(): + self._bash_task.cancel() + self._log_reader.shutdown() finally: self.exit(result=self._get_session_resume_info()) @@ -3348,14 +3229,7 @@ class VibeApp(App): # noqa: PLR0904 if self._chat_input_container: self._chat_input_container.set_safety(profile.safety) self._chat_input_container.set_agent_name(profile.display_name.lower()) - if self._remote_manager.is_active: - session_id = self._remote_manager.session_id - self._chat_input_container.set_custom_border( - f"Remote session {short_session_id(session_id, source='remote') if session_id else ''}", - ChatInputContainer.REMOTE_BORDER_CLASS, - ) - else: - self._chat_input_container.set_custom_border(None) + self._chat_input_container.set_custom_border(None) async def _cycle_agent(self) -> None: new_profile = self.agent_loop.agent_manager.next_agent( @@ -3445,31 +3319,52 @@ class VibeApp(App): # noqa: PLR0904 def _emit_session_closed_for_active_session(self) -> None: self.agent_loop.emit_session_closed_telemetry() + async def _begin_shutdown(self) -> None: + await self._queue.shutdown() + await self._loop_runner.stop() + def _force_quit(self) -> None: if self._force_quit_task is not None and not self._force_quit_task.done(): return self._force_quit_task = asyncio.create_task(self._force_quit_async()) async def _force_quit_async(self) -> None: - self._emit_session_closed_for_active_session() - if self._agent_task and not self._agent_task.done(): - self._agent_task.cancel() - if self._bash_task and not self._bash_task.done(): - self._bash_task.cancel() - self._remote_manager.cancel_stream_task() + try: + self._emit_session_closed_for_active_session() + await self._begin_shutdown() + if self._agent_task and not self._agent_task.done(): + self._agent_task.cancel() + if self._bash_task and not self._bash_task.done(): + self._bash_task.cancel() + self._log_reader.shutdown() + self._narrator_manager.cancel() + finally: + self.exit(result=self._get_session_resume_info()) - self._log_reader.shutdown() - self._narrator_manager.cancel() - await self._close_remote_resume() - await self.agent_loop.aclose() + async def shutdown_cleanup(self) -> None: + with suppress(Exception): + await self._begin_shutdown() + for task in (self._agent_task, self._bash_task): + if task is None or task.done(): + continue + task.cancel() + for task in (self._agent_task, self._bash_task): + if task is None or task.done(): + continue + with suppress(asyncio.CancelledError, Exception): + await task + with suppress(Exception): + await self._voice_manager.close() + with suppress(Exception): + await self._narrator_manager.close() + with suppress(Exception): + await self.agent_loop.aclose() try: await self.agent_loop.telemetry_client.aclose() except Exception as exc: logger.error( - "Failed to close telemetry client during force quit", exc_info=exc + "Failed to close telemetry client during shutdown", exc_info=exc ) - finally: - self.exit(result=self._get_session_resume_info()) def action_scroll_chat_up(self) -> None: try: @@ -3709,13 +3604,27 @@ class VibeApp(App): # noqa: PLR0904 ) +async def _run_app_with_cleanup(app: VibeApp) -> str | None: + from vibe.cli.stderr_guard import stderr_guard + + try: + with stderr_guard(): + return await app.run_async() + finally: + sys.stderr.write("Closing\u2026\r") + sys.stderr.flush() + try: + await app.shutdown_cleanup() + finally: + sys.stderr.write("\033[2K\r") + sys.stderr.flush() + + def run_textual_ui( agent_loop: AgentLoop, update_cache_repository: UpdateCacheRepository, startup: StartupOptions | None = None, ) -> None: - from vibe.cli.stderr_guard import stderr_guard - update_notifier = PyPIUpdateGateway(project_name="mistral-vibe") plan_offer_gateway = HttpWhoAmIGateway(base_url=agent_loop.config.console_base_url) vscode_extension_promo_repository = FileSystemVscodeExtensionPromoRepository() @@ -3724,16 +3633,15 @@ def run_textual_ui( initial_state=asyncio.run(vscode_extension_promo_repository.get()), ) - with stderr_guard(): - app = VibeApp( - agent_loop=agent_loop, - startup=startup, - update_notifier=update_notifier, - update_cache_repository=update_cache_repository, - plan_offer_gateway=plan_offer_gateway, - vscode_extension_promo=vscode_extension_promo, - ) - session_id = app.run() + app = VibeApp( + agent_loop=agent_loop, + startup=startup, + update_notifier=update_notifier, + update_cache_repository=update_cache_repository, + plan_offer_gateway=plan_offer_gateway, + vscode_extension_promo=vscode_extension_promo, + ) + session_id = asyncio.run(_run_app_with_cleanup(app)) print_session_resume_message( session_id, agent_loop.stats, agent_loop.config.session_logging diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss index 80f4a35..0483e4b 100644 --- a/vibe/cli/textual_ui/app.tcss +++ b/vibe/cli/textual_ui/app.tcss @@ -116,17 +116,15 @@ TextArea > .text-area--cursor { } #completion-popup { - overlay: screen; - constrain: inside inside; display: none; - width: auto; + width: 100%; height: auto; color: $foreground; background: $background; border: solid $foreground-muted; overflow-y: auto; scrollbar-size-vertical: 1; - /* max-height, max-width and padding set in completion_popup.py */ + /* max-height and padding set in completion_popup.py */ } OptionList, OptionList:focus { @@ -134,11 +132,40 @@ OptionList, OptionList:focus { background-tint: transparent; } -#completion-popup _CompletionItem { +#completion-popup _CompletionRow { height: auto; width: 1fr; } +#completion-popup _CompletionItem { + height: auto; +} + +#completion-popup .completion-command { + /* width set per-row in completion_popup.py, capped to 30% of the popup */ + max-width: 30%; + margin-right: 2; + text-style: bold; +} + +#completion-popup .completion-description { + width: 1fr; + color: $text-muted; + + &:ansi { + text-style: dim; + } +} + +#completion-popup _CompletionRow.completion-selected .completion-command { + text-style: bold reverse; +} + +#completion-popup _CompletionRow.completion-selected .completion-description { + color: $foreground; + text-style: italic; +} + #input-box { height: auto; width: 100%; @@ -172,12 +199,6 @@ OptionList, OptionList:focus { border-bottom: solid $mistral_orange; border-title-color: $mistral_orange; } - - &.border-remote { - border-top: solid $mistral_orange; - border-bottom: solid $mistral_orange; - border-title-color: $mistral_orange; - } } #input-body { @@ -308,7 +329,7 @@ Markdown { .user-message-attachments { width: 100%; height: auto; - padding-left: 2; + padding: 0 2 0 2; color: $text-muted; &:ansi { @@ -350,7 +371,7 @@ Markdown { margin-top: 1; width: 100%; height: auto; - padding-left: 2; + padding: 0 2 0 2; Markdown { width: 100%; @@ -412,7 +433,7 @@ Markdown { layout: stream; width: 100%; height: auto; - padding: 0 0 0 2; + padding: 0 2 0 2; margin: 0; color: $text-muted; text-style: italic; @@ -497,6 +518,7 @@ Markdown { width: 1fr; height: auto; margin: 0; + padding-right: 2; color: $warning; } @@ -504,6 +526,7 @@ Markdown { width: 1fr; height: auto; margin: 0; + padding-right: 2; color: $error; text-style: bold; } @@ -512,6 +535,7 @@ Markdown { width: 1fr; height: auto; margin: 0; + padding-right: 2; color: $warning; } @@ -519,6 +543,7 @@ Markdown { width: 1fr; height: auto; margin: 0; + padding-right: 2; Markdown { margin: 0; @@ -605,6 +630,7 @@ Markdown { .bash-output-body { width: 1fr; height: auto; + padding-right: 2; } .bash-output { @@ -716,7 +742,7 @@ StatusMessage { width: 100%; height: auto; color: $text-muted; - padding-left: 2; + padding: 0 2 0 2; &:ansi { text-style: dim; @@ -762,7 +788,7 @@ StatusMessage { .tool-result-content { width: 1fr; height: auto; - padding: 0; + padding-right: 2; } .tool-call-widget, diff --git a/vibe/cli/textual_ui/handlers/event_handler.py b/vibe/cli/textual_ui/handlers/event_handler.py index 94f4ccc..3763e54 100644 --- a/vibe/cli/textual_ui/handlers/event_handler.py +++ b/vibe/cli/textual_ui/handlers/event_handler.py @@ -14,7 +14,6 @@ from vibe.cli.textual_ui.widgets.messages import ( HookSystemMessageLine, PlanFileMessage, ReasoningMessage, - UserMessage, ) from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage @@ -55,12 +54,10 @@ class EventHandler: mount_callback: Callable, get_tools_collapsed: Callable[[], bool], on_profile_changed: Callable[[], None] | None = None, - is_remote: bool = False, ) -> None: self.mount_callback = mount_callback self.get_tools_collapsed = get_tools_collapsed self.on_profile_changed = on_profile_changed - self.is_remote = is_remote self.tool_calls: dict[str, ToolCallMessage] = {} self.current_compact: CompactMessage | None = None self.current_streaming_message: AssistantMessage | None = None @@ -168,8 +165,6 @@ class EventHandler: pass case UserMessageEvent(): await self.finalize_streaming() - if self.is_remote: - await self.mount_callback(UserMessage(event.content)) case HookEvent(): await self._handle_hook_event(event, loading_widget) case PlanReviewRequestedEvent(): diff --git a/vibe/cli/textual_ui/message_queue.py b/vibe/cli/textual_ui/message_queue.py index 87d7e6b..6d06702 100644 --- a/vibe/cli/textual_ui/message_queue.py +++ b/vibe/cli/textual_ui/message_queue.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable +from contextlib import suppress from dataclasses import dataclass, field from enum import StrEnum, auto from pathlib import Path @@ -121,8 +122,6 @@ class QueuePorts: agent_running: Callable[[], bool] bash_task: Callable[[], asyncio.Task | None] active_model: Callable[[], ModelConfig | None] - remote_is_active: Callable[[], bool] - remote_stop_stream: Callable[[], Awaitable[None]] remove_loading_widget: Callable[[], Awaitable[None]] set_loading_queue_count: Callable[[int], None] inject_user_context: Callable[..., Awaitable[None]] @@ -130,7 +129,6 @@ class QueuePorts: start_agent_turn: Callable[..., asyncio.Task] await_agent_turn: Callable[[], Awaitable[None]] run_bash: Callable[..., asyncio.Task] - handle_user_message: Callable[[str], Awaitable[None]] maybe_show_feedback_bar: Callable[[], None] send_skill_telemetry: Callable[[str | None], None] send_at_mention_telemetry: Callable[[PathPromptPayload, str], None] @@ -157,6 +155,7 @@ class QueueController: self._widgets: list[Widget] = [] self._header: QueueHeaderMessage | None = None self._drain_task: asyncio.Task | None = None + self._drain_enabled = True @property def queue(self) -> MessageQueue: @@ -275,6 +274,8 @@ class QueueController: # -- drain engine ----------------------------------------------------- def start_drain_if_needed(self) -> None: + if not self._drain_enabled: + return if self._drain_task is not None and not self._drain_task.done(): return if not self._queue or self._queue.paused: @@ -290,9 +291,18 @@ class QueueController: def draining(self) -> bool: return self._drain_task is not None and not self._drain_task.done() + async def shutdown(self) -> None: + self._drain_enabled = False + drain_task = self._drain_task + if drain_task is None or drain_task.done(): + return + drain_task.cancel() + with suppress(asyncio.CancelledError, Exception): + await drain_task + async def _drain(self) -> None: try: - while self._queue and not self._queue.paused: + while self._drain_enabled and self._queue and not self._queue.paused: await self._remove_header() pending = await self._consume_until_bash_or_empty() if not pending: @@ -390,17 +400,10 @@ class QueueController: self._ports.send_at_mention_telemetry(item.payload, message_id) async def _run_tail_prompt(self, item: QueuedItem, widget: UserMessage) -> None: - if self._ports.remote_is_active(): - await widget.remove() - await self._ports.handle_user_message(item.content) - self._ports.send_skill_telemetry(item.skill_name) - return - widget.message_index = self._ports.next_message_index() await widget.set_pending(False) self._ports.maybe_show_feedback_bar() - await self._ports.remote_stop_stream() await self._ports.remove_loading_widget() self._ports.start_agent_turn( item.content, prebuilt_images=item.images, prebuilt_payload=item.payload @@ -416,6 +419,9 @@ class QueueController: try: await bash_task except asyncio.CancelledError: + current = asyncio.current_task() + if current is not None and current.cancelling(): + raise return False return True diff --git a/vibe/cli/textual_ui/remote/__init__.py b/vibe/cli/textual_ui/remote/__init__.py deleted file mode 100644 index b8d7a78..0000000 --- a/vibe/cli/textual_ui/remote/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import annotations - -from vibe.cli.textual_ui.remote.remote_session_manager import ( - RemoteSessionManager, - is_progress_event, -) - -__all__ = ["RemoteSessionManager", "is_progress_event"] diff --git a/vibe/cli/textual_ui/remote/remote_session_manager.py b/vibe/cli/textual_ui/remote/remote_session_manager.py deleted file mode 100644 index 18a8fcb..0000000 --- a/vibe/cli/textual_ui/remote/remote_session_manager.py +++ /dev/null @@ -1,211 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import Any, Protocol - -from vibe.cli.textual_ui.widgets.loading import DEFAULT_LOADING_STATUS -from vibe.core.config import VibeConfig -from vibe.core.nuage.remote_events_source import RemoteEventsSource -from vibe.core.tools.builtins.ask_user_question import ( - AskUserQuestionArgs, - Choice, - Question, -) -from vibe.core.types import ( - AssistantEvent, - BaseEvent, - ReasoningEvent, - ToolCallEvent, - ToolStreamEvent, - UserMessageEvent, - WaitingForInputEvent, -) - -_MIN_QUESTION_OPTIONS = 2 -_MAX_QUESTION_OPTIONS = 4 - - -class RemoteSessionUI(Protocol): - async def on_remote_event(self, event: BaseEvent, loading_widget: Any) -> None: ... - async def on_remote_waiting_input(self, event: WaitingForInputEvent) -> None: ... - async def on_remote_user_message_cleared_input(self) -> None: ... - async def on_remote_stream_error(self, error: str) -> None: ... - async def on_remote_stream_ended(self, msg_type: str, text: str) -> None: ... - async def on_remote_finalize_streaming(self) -> None: ... - async def remove_loading(self) -> None: ... - async def ensure_loading(self, status: str = DEFAULT_LOADING_STATUS) -> None: ... - @property - def loading_widget(self) -> Any: ... - - -def is_progress_event(event: object) -> bool: - return isinstance( - event, (AssistantEvent, ReasoningEvent, ToolCallEvent, ToolStreamEvent) - ) - - -class RemoteSessionManager: - def __init__(self) -> None: - self._events_source: RemoteEventsSource | None = None - self._stream_task: asyncio.Task | None = None - self._pending_waiting_input: WaitingForInputEvent | None = None - - @property - def is_active(self) -> bool: - return self._events_source is not None - - @property - def is_terminated(self) -> bool: - if self._events_source is None: - return False - return self._events_source.is_terminated - - @property - def is_waiting_for_input(self) -> bool: - if self._events_source is None: - return False - return self._events_source.is_waiting_for_input - - @property - def has_pending_input(self) -> bool: - return self._pending_waiting_input is not None - - @property - def session_id(self) -> str | None: - if self._events_source is None: - return None - return self._events_source.session_id - - async def attach(self, session_id: str, config: VibeConfig) -> None: - await self.detach() - self._events_source = RemoteEventsSource(session_id=session_id, config=config) - - async def detach(self) -> None: - await self._stop_stream() - if self._events_source is not None: - await self._events_source.close() - self._events_source = None - self._pending_waiting_input = None - - def validate_input(self) -> str | None: - if self.is_terminated: - return ( - "Remote session has ended. Use /clear to start a new session" - " or /resume to attach to another." - ) - if not self.is_waiting_for_input: - return ( - "Remote session is not waiting for input. Please wait for the" - " current task to complete." - ) - return None - - async def send_prompt(self, message: str, *, require_source: bool = True) -> None: - if self._events_source is None: - if require_source: - raise RuntimeError("No active remote session") - return - saved_pending = self._pending_waiting_input - self._pending_waiting_input = None - try: - await self._events_source.send_prompt(message) - except Exception: - self._pending_waiting_input = saved_pending - raise - - def cancel_pending_input(self) -> None: - self._pending_waiting_input = None - - def build_question_args( - self, event: WaitingForInputEvent - ) -> AskUserQuestionArgs | None: - if ( - not event.predefined_answers - or len(event.predefined_answers) < _MIN_QUESTION_OPTIONS - ): - return None - - question = event.label or "Choose an answer" - return AskUserQuestionArgs( - questions=[ - Question( - question=question, - options=[ - Choice(label=answer) - for answer in event.predefined_answers[:_MAX_QUESTION_OPTIONS] - ], - ) - ] - ) - - def set_pending_input(self, event: WaitingForInputEvent) -> None: - self._pending_waiting_input = event - - def start_stream(self, ui: RemoteSessionUI) -> None: - if self._events_source is None: - return - if self._stream_task and not self._stream_task.done(): - return - self._stream_task = asyncio.create_task( - self._consume_stream(ui), name="remote-session-stream" - ) - - async def stop_stream(self) -> None: - await self._stop_stream() - - def build_terminal_message(self) -> tuple[str, str]: - if self._events_source is None: - return ("info", "Remote session completed") - if self._events_source.is_failed: - return ("error", "Remote session failed") - if self._events_source.is_canceled: - return ("warning", "Remote session was canceled") - return ("info", "Remote session completed") - - def cancel_stream_task(self) -> None: - if self._stream_task and not self._stream_task.done(): - self._stream_task.cancel() - - async def _stop_stream(self) -> None: - if self._stream_task is None or self._stream_task.done(): - self._stream_task = None - return - self._stream_task.cancel() - try: - await self._stream_task - except asyncio.CancelledError: - pass - self._stream_task = None - - async def _consume_stream(self, ui: RemoteSessionUI) -> None: - events_source = self._events_source - if events_source is None: - return - await ui.ensure_loading(DEFAULT_LOADING_STATUS) - try: - async for event in events_source.attach(): - if isinstance(event, WaitingForInputEvent): - await ui.remove_loading() - self._pending_waiting_input = event - await ui.on_remote_waiting_input(event) - elif ( - isinstance(event, UserMessageEvent) - and self._pending_waiting_input is not None - ): - self._pending_waiting_input = None - await ui.on_remote_user_message_cleared_input() - elif ui.loading_widget is None and is_progress_event(event): - await ui.ensure_loading() - await ui.on_remote_event(event, loading_widget=ui.loading_widget) - except asyncio.CancelledError: - raise - except Exception as e: - await ui.on_remote_stream_error(f"Remote stream stopped: {e}") - finally: - await ui.on_remote_finalize_streaming() - await ui.remove_loading() - self._stream_task = None - self._pending_waiting_input = None - if events_source.is_terminated: - msg_type, text = self.build_terminal_message() - await ui.on_remote_stream_ended(msg_type, text) diff --git a/vibe/cli/textual_ui/widgets/chat_input/completion_popup.py b/vibe/cli/textual_ui/widgets/chat_input/completion_popup.py index 4aa6000..b2249a4 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +++ b/vibe/cli/textual_ui/widgets/chat_input/completion_popup.py @@ -3,27 +3,30 @@ from __future__ import annotations from typing import Any from rich.cells import cell_len -from rich.text import Text -from textual.containers import VerticalScroll +from textual.containers import Horizontal, VerticalScroll from textual.widgets import Static COMPLETION_POPUP_MAX_HEIGHT = 12 -COMPLETION_POPUP_MAX_WIDTH = 80 COMPLETION_POPUP_PADDING_X = 1 +SELECTED_CLASS = "completion-selected" class _CompletionItem(Static): pass +class _CompletionRow(Horizontal): + pass + + class CompletionPopup(VerticalScroll): def __init__(self, **kwargs: Any) -> None: super().__init__(id="completion-popup", **kwargs) self.styles.display = "none" self.styles.max_height = COMPLETION_POPUP_MAX_HEIGHT - self.styles.max_width = COMPLETION_POPUP_MAX_WIDTH self.styles.padding = (0, COMPLETION_POPUP_PADDING_X) self.can_focus = False + self._suggestions: list[tuple[str, str]] = [] def update_suggestions( self, suggestions: list[tuple[str, str]], selected: int @@ -32,30 +35,42 @@ class CompletionPopup(VerticalScroll): self.hide() return - self.remove_children() - - items: list[_CompletionItem] = [] - for idx, (label, description) in enumerate(suggestions): - text = Text() - label_style = "bold reverse" if idx == selected else "bold" - description_style = "italic" if idx == selected else "dim" - - text.append(self._display_label(label), style=label_style) - if description: - text.append(" ") - text.append(description, style=description_style) - - item = _CompletionItem(text) - items.append(item) - - self.mount_all(items) + if suggestions != self._suggestions: + rows = self._rebuild(suggestions) + else: + rows = list(self.query(_CompletionRow)) + self._select(rows, selected) self.styles.display = "block" - if 0 <= selected < len(items): - items[selected].scroll_visible(animate=False) + def _rebuild(self, suggestions: list[tuple[str, str]]) -> list[_CompletionRow]: + self.remove_children() + self._suggestions = suggestions + command_width = max( + cell_len(self._display_label(label)) for label, _ in suggestions + ) + rows: list[_CompletionRow] = [] + for label, description in suggestions: + command = _CompletionItem( + self._display_label(label), classes="completion-command" + ) + command.styles.width = command_width + description_cell = _CompletionItem( + description, classes="completion-description" + ) + rows.append(_CompletionRow(command, description_cell)) + self.mount_all(rows) + return rows + + @staticmethod + def _select(rows: list[_CompletionRow], selected: int) -> None: + for idx, row in enumerate(rows): + row.set_class(idx == selected, SELECTED_CLASS) + if 0 <= selected < len(rows): + rows[selected].scroll_visible(animate=False) def hide(self) -> None: self.remove_children() + self._suggestions = [] self.styles.display = "none" @property @@ -67,10 +82,3 @@ class CompletionPopup(VerticalScroll): if label.startswith("@"): return label[1:] return label - - @classmethod - def rendered_text_length(cls, label: str, description: str) -> int: - text_length = cell_len(cls._display_label(label)) + cell_len(description) - if description: - text_length += 2 - return text_length diff --git a/vibe/cli/textual_ui/widgets/chat_input/container.py b/vibe/cli/textual_ui/widgets/chat_input/container.py index 9af2bcf..00c903c 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/container.py +++ b/vibe/cli/textual_ui/widgets/chat_input/container.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections.abc import Callable -import math from pathlib import Path from typing import Any @@ -16,12 +15,7 @@ from vibe.cli.textual_ui.widgets.chat_input.body import ChatInputBody from vibe.cli.textual_ui.widgets.chat_input.completion_manager import ( MultiCompletionManager, ) -from vibe.cli.textual_ui.widgets.chat_input.completion_popup import ( - COMPLETION_POPUP_MAX_HEIGHT, - COMPLETION_POPUP_MAX_WIDTH, - COMPLETION_POPUP_PADDING_X, - CompletionPopup, -) +from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea from vibe.cli.voice_manager.voice_manager_port import VoiceManagerPort from vibe.core.agents import AgentSafety @@ -34,15 +28,8 @@ SAFETY_BORDER_CLASSES: dict[AgentSafety, str] = { } -COMPLETION_POPUP_MAX_LINES = COMPLETION_POPUP_MAX_HEIGHT - 2 -COMPLETION_POPUP_MAX_CHARS = ( - COMPLETION_POPUP_MAX_WIDTH - 2 * COMPLETION_POPUP_PADDING_X - 2 -) # -2 for borders - - class ChatInputContainer(Vertical): ID_INPUT_BOX = "input-box" - REMOTE_BORDER_CLASS = "border-remote" class Submitted(Message): def __init__(self, value: str) -> None: @@ -71,7 +58,6 @@ class ChatInputContainer(Vertical): ) self._voice_manager = voice_manager self._custom_border_label: str | None = None - self._custom_border_class: str | None = None self._completion_manager = MultiCompletionManager([ SlashCommandController(CommandCompleter(self._get_slash_entries), self), @@ -157,7 +143,6 @@ class ChatInputContainer(Vertical): except Exception: return popup.update_suggestions(suggestions, selected_index) - self._position_popup(popup, suggestions) def clear_completion_suggestions(self) -> None: try: @@ -166,29 +151,6 @@ class ChatInputContainer(Vertical): return popup.hide() - def _compute_line_count(self, suggestions: list[tuple[str, str]]) -> int: - line_count_without_scrollbar = sum( - math.ceil( - CompletionPopup.rendered_text_length(label, description) - / COMPLETION_POPUP_MAX_CHARS - ) - for label, description in suggestions - ) - return min(line_count_without_scrollbar, COMPLETION_POPUP_MAX_LINES) - - def _position_popup( - self, popup: CompletionPopup, suggestions: list[tuple[str, str]] - ) -> None: - widget = self.input_widget - if not widget: - return - cursor = widget.cursor_screen_offset - my_region = self.region - # Place popup bottom edge just above the cursor row - popup_height = self._compute_line_count(suggestions) + 2 # +2 for solid border - offset = (cursor.x - my_region.x, cursor.y - popup_height - my_region.y) - popup.styles.offset = offset - def _format_insertion(self, replacement: str, suffix: str) -> str: """Format the insertion text with appropriate spacing. @@ -208,7 +170,9 @@ class ChatInputContainer(Vertical): # For other completions, add space only if suffix exists and doesn't start with whitespace return replacement + (" " if suffix and not suffix[0].isspace() else "") - def replace_completion_range(self, start: int, end: int, replacement: str) -> None: + def replace_completion_range( + self, start: int, end: int, replacement: str, *, suppress_update: bool = False + ) -> None: widget = self.input_widget if not widget or not self._body: return @@ -225,6 +189,8 @@ class ChatInputContainer(Vertical): insertion = self._format_insertion(replacement, suffix) new_text = f"{prefix}{insertion}{suffix}" + if suppress_update: + widget.applying_completion = True self._body.replace_input(new_text, cursor_offset=start + len(insertion)) def on_chat_input_body_submitted(self, event: ChatInputBody.Submitted) -> None: @@ -248,16 +214,11 @@ class ChatInputContainer(Vertical): self._agent_name = name self._apply_input_box_chrome() - def set_custom_border( - self, label: str | None, border_class: str | None = None - ) -> None: + def set_custom_border(self, label: str | None) -> None: self._custom_border_label = label - self._custom_border_class = border_class self._apply_input_box_chrome() def _get_border_class(self) -> str: - if self._custom_border_class is not None: - return self._custom_border_class if self._custom_border_label is not None: return "" return SAFETY_BORDER_CLASSES.get(self._safety, "") @@ -272,7 +233,6 @@ class ChatInputContainer(Vertical): except Exception: return - input_box.remove_class(self.REMOTE_BORDER_CLASS) for border_class in SAFETY_BORDER_CLASSES.values(): input_box.remove_class(border_class) 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 a115b4f..d94a240 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/text_area.py +++ b/vibe/cli/textual_ui/widgets/chat_input/text_area.py @@ -76,6 +76,7 @@ class ChatTextArea(TextArea): self._input_mode: InputMode = self.DEFAULT_MODE self._last_text = "" self._navigating_history = False + self._applying_completion = False self._original_text: str = "" self._cursor_pos_after_load: tuple[int, int] | None = None self._cursor_moved_since_load: bool = False @@ -144,8 +145,14 @@ class ChatTextArea(TextArea): self._last_text = self.text was_navigating_history = self._navigating_history self._navigating_history = False + was_applying_completion = self._applying_completion + self._applying_completion = False - if self._completion_manager and not was_navigating_history: + if ( + self._completion_manager + and not was_navigating_history + and not was_applying_completion + ): self._completion_manager.on_text_changed( self.get_full_text(), self._get_full_cursor_offset() ) @@ -318,6 +325,14 @@ class ChatTextArea(TextArea): await super()._on_key(event) self._mark_cursor_moved_if_needed() + @property + def applying_completion(self) -> bool: + return self._applying_completion + + @applying_completion.setter + def applying_completion(self, value: bool) -> None: + self._applying_completion = value + def set_completion_manager(self, manager: MultiCompletionManager | None) -> None: self._completion_manager = manager if self._completion_manager: diff --git a/vibe/cli/textual_ui/widgets/diff_rendering.py b/vibe/cli/textual_ui/widgets/diff_rendering.py index d1dbe3d..2030ff7 100644 --- a/vibe/cli/textual_ui/widgets/diff_rendering.py +++ b/vibe/cli/textual_ui/widgets/diff_rendering.py @@ -16,7 +16,7 @@ from textual.widgets import Static from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.utils.io import read_safe -from vibe.core.utils.text import snippet_start_line +from vibe.core.utils.text import snippet_start_lines _HUNK_HEADER_RE = re.compile(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@") @@ -42,11 +42,11 @@ def language_for_path(file_path: str) -> str: return Path(file_path).suffix.lstrip(".") or "text" -def locate_snippet_in_file(file_path: str, snippet: str) -> int | None: +def locate_snippets_in_file(file_path: str, snippet: str) -> list[int]: path = Path(file_path) if not path.is_file(): - return None - return snippet_start_line(read_safe(path).text, snippet) + return [] + return snippet_start_lines(read_safe(path).text, snippet) def _pick_theme(*, ansi: bool, dark: bool) -> type[HighlightTheme]: @@ -98,7 +98,7 @@ def render_edit_diff( old_string: str, new_string: str, language: str, - start_line: int | None, + start_lines: list[int] | None, *, ansi: bool, dark: bool, @@ -113,6 +113,30 @@ def render_edit_diff( ) )[2:] + # No known locations: render the hunk once without gutter line numbers. + if not start_lines: + return _render_occurrence(diff_lines, None, language, ansi=ansi, theme=theme) + + # replace_all repeats the same change at each match; render one block per + # occurrence, anchored at its own line number, with a gap in between. + widgets: list[Static] = [] + for index, start_line in enumerate(start_lines): + if index > 0: + widgets.append(NoMarkupStatic("⋯", classes="diff-gap")) + widgets.extend( + _render_occurrence(diff_lines, start_line, language, ansi=ansi, theme=theme) + ) + return widgets + + +def _render_occurrence( + diff_lines: list[str], + start_line: int | None, + language: str, + *, + ansi: bool, + theme: type[HighlightTheme], +) -> list[Static]: offset = (start_line - 1) if start_line else 0 old_lineno = new_lineno = 0 # overwritten by the first @@ header widgets: list[Static] = [] diff --git a/vibe/cli/textual_ui/widgets/feedback_bar_manager.py b/vibe/cli/textual_ui/widgets/feedback_bar_manager.py index bd652c9..4d9f9b9 100644 --- a/vibe/cli/textual_ui/widgets/feedback_bar_manager.py +++ b/vibe/cli/textual_ui/widgets/feedback_bar_manager.py @@ -17,7 +17,8 @@ class FeedbackBarManager: telemetry_active=agent_loop.telemetry_client.is_active(), is_mistral_model=agent_loop.config.is_active_model_mistral(), user_message_count=user_message_count, + cache_store=agent_loop.cache_store, ) - def record_feedback_asked(self) -> None: - record_feedback_asked() + def record_feedback_asked(self, agent_loop: AgentLoop) -> None: + record_feedback_asked(agent_loop.cache_store) diff --git a/vibe/cli/textual_ui/widgets/messages.py b/vibe/cli/textual_ui/widgets/messages.py index 07b2fcc..1496ae6 100644 --- a/vibe/cli/textual_ui/widgets/messages.py +++ b/vibe/cli/textual_ui/widgets/messages.py @@ -8,7 +8,7 @@ from rich.markup import escape from vibe.core.hooks.models import HookMessageSeverity from vibe.core.logger import logger -from vibe.core.types import ImageAttachment +from vibe.core.types import FileImageSource, ImageAttachment, InlineImageSource from vibe.core.utils.io import read_safe_async if TYPE_CHECKING: @@ -120,16 +120,23 @@ class UserMessage(Static): if self._pending: self.add_class("pending") + @staticmethod + def _format_attachment_link(att: ImageAttachment) -> str: + match att.source: + case FileImageSource(path=path): + # Quote the URL in Textual [link="..."] markup: the parser stops + # at `:` inside an unquoted tag value, so a raw `file://...` URL + # would raise MarkupError. Textual auto-wires the click to + # webbrowser.open(url), opening the OS default viewer. + return f'[link="{path.as_uri()}"]{escape(att.alias)}[/link]' + case InlineImageSource(): + # Inline images have no file on disk, so there's nothing to link. + return escape(att.alias) + @staticmethod def _format_attachments_footer(images: list[ImageAttachment]) -> str: label = "attached image" if len(images) == 1 else "attached images" - # Use Textual [link="..."] markup with the URL quoted: Textual's - # markup parser stops at `:` inside an unquoted tag value, so a raw - # `file://...` URL would raise MarkupError. Textual auto-wires the - # click to webbrowser.open(url), opening the OS default viewer. - links = ", ".join( - f'[link="{att.path.as_uri()}"]{escape(att.alias)}[/link]' for att in images - ) + links = ", ".join(UserMessage._format_attachment_link(att) for att in images) return f"└ {label}: {links}" async def set_pending(self, pending: bool) -> None: diff --git a/vibe/cli/textual_ui/widgets/session_picker.py b/vibe/cli/textual_ui/widgets/session_picker.py index d0792f3..c74cc08 100644 --- a/vibe/cli/textual_ui/widgets/session_picker.py +++ b/vibe/cli/textual_ui/widgets/session_picker.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import UTC, datetime -from typing import Any, ClassVar, Literal, cast +from typing import Any, ClassVar, Literal from rich.text import Text from textual.app import ComposeResult @@ -13,11 +13,7 @@ from textual.widgets import OptionList from textual.widgets.option_list import Option from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic -from vibe.core.session.resume_sessions import ( - ResumeSessionInfo, - ResumeSessionSource, - short_session_id, -) +from vibe.core.session.resume_sessions import ResumeSessionInfo, short_session_id _SECONDS_PER_MINUTE = 60 _SECONDS_PER_HOUR = 3600 @@ -57,26 +53,19 @@ def _format_relative_time(iso_time: str | None) -> str: return "unknown" -def _build_header_text(cwd: str | None, has_remote: bool) -> Text: +def _build_header_text(cwd: str | None) -> Text: text = Text(no_wrap=True) text.append("local ", style="cyan") text.append(cwd or "this folder", style="dim") - if has_remote: - text.append(" · ", style="dim") - text.append("remote ", style="cyan") - text.append("all folders", style="dim") return text def _build_option_text(session: ResumeSessionInfo, message: str) -> Text: text = Text(no_wrap=True) time_str = _format_relative_time(session.end_time) - session_id = short_session_id(session.session_id, source=session.source) - source = session.source + session_id = short_session_id(session.session_id) text.append(f"{time_str:10}", style="dim") text.append(" ") - text.append(f"{source:6}", style="cyan") - text.append(" ") text.append(f"{session_id} ", style="dim") text.append(message) return text @@ -94,14 +83,10 @@ class SessionPickerApp(Container): class SessionSelected(Message): option_id: str - source: ResumeSessionSource session_id: str - def __init__( - self, option_id: str, source: ResumeSessionSource, session_id: str - ) -> None: + def __init__(self, option_id: str, session_id: str) -> None: self.option_id = option_id - self.source = source self.session_id = session_id super().__init__() @@ -110,14 +95,10 @@ class SessionPickerApp(Container): class SessionDeleteRequested(Message): option_id: str - source: ResumeSessionSource session_id: str - def __init__( - self, option_id: str, source: ResumeSessionSource, session_id: str - ) -> None: + def __init__(self, option_id: str, session_id: str) -> None: self.option_id = option_id - self.source = source self.session_id = session_id super().__init__() @@ -196,9 +177,6 @@ class SessionPickerApp(Container): if session.session_id == self._current_session_id: return "Can't delete current session" - if not session.can_delete: - return "Can't delete remote session" - return "Can't delete session" def _delete_pending_option_text(self, session: ResumeSessionInfo) -> Text: @@ -282,9 +260,8 @@ class SessionPickerApp(Container): return def _refresh_header(self) -> None: - has_remote = any(session.source == "remote" for session in self._sessions) header = self.query_one(".sessionpicker-header", NoMarkupStatic) - header.update(_build_header_text(self._cwd, has_remote)) + header.update(_build_header_text(self._cwd)) def clear_pending_delete(self, option_id: str) -> bool: if not self._delete_state_matches(option_id, "pending"): @@ -298,11 +275,9 @@ class SessionPickerApp(Container): Option(self._normal_option_text(session), id=session.option_id) for session in self._sessions ] - has_remote = any(session.source == "remote" for session in self._sessions) with Vertical(id="sessionpicker-content"): yield NoMarkupStatic( - _build_header_text(self._cwd, has_remote), - classes="sessionpicker-header", + _build_header_text(self._cwd), classes="sessionpicker-header" ) yield OptionList(*options, id="sessionpicker-options") yield NoMarkupStatic( @@ -332,13 +307,8 @@ class SessionPickerApp(Container): if self._delete_state_matches(option_id, "confirmation"): return - source, _, session_id = option_id.partition(":") self.post_message( - self.SessionSelected( - option_id=option_id, - source=cast(ResumeSessionSource, source), - session_id=session_id, - ) + self.SessionSelected(option_id=option_id, session_id=option_id) ) def action_cancel(self) -> None: @@ -359,7 +329,7 @@ class SessionPickerApp(Container): if session is None: return - if session.session_id == self._current_session_id or not session.can_delete: + if session.session_id == self._current_session_id: self._show_delete_state( session, "feedback", self._delete_feedback_option_text(session) ) @@ -371,9 +341,7 @@ class SessionPickerApp(Container): ) self.post_message( self.SessionDeleteRequested( - option_id=session.option_id, - source=session.source, - session_id=session.session_id, + option_id=session.option_id, session_id=session.session_id ) ) return diff --git a/vibe/cli/textual_ui/widgets/tool_widgets.py b/vibe/cli/textual_ui/widgets/tool_widgets.py index 8f783a8..50581c0 100644 --- a/vibe/cli/textual_ui/widgets/tool_widgets.py +++ b/vibe/cli/textual_ui/widgets/tool_widgets.py @@ -15,7 +15,7 @@ from vibe.cli.textual_ui.widgets.collapsible import CollapsibleSection, lines_la from vibe.cli.textual_ui.widgets.diff_rendering import ( diff_border_colors, language_for_path, - locate_snippet_in_file, + locate_snippets_in_file, render_edit_diff, ) from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic @@ -221,13 +221,15 @@ class EditApprovalWidget(ToolApprovalWidget[EditArgs]): ) yield NoMarkupStatic("") - # Approximate: queued edits ahead of this one may shift the real line. - start_line = locate_snippet_in_file(self.args.file_path, self.args.old_string) + # Approximate: queued edits ahead of this one may shift the real lines. + start_lines = locate_snippets_in_file(self.args.file_path, self.args.old_string) + if not self.args.replace_all: + start_lines = start_lines[:1] yield from render_edit_diff( self.args.old_string, self.args.new_string, language_for_path(self.args.file_path), - start_line, + start_lines, ansi=self.app.native_ansi_color, dark=self.app.current_theme.dark, ) @@ -252,7 +254,7 @@ class EditResultWidget(ToolResultWidget[EditResult]): self.result.old_string, self.result.new_string, language_for_path(self.result.file), - self.result.ui_start_line, + self.result.ui_start_lines, ansi=self.app.native_ansi_color, dark=self.app.current_theme.dark, ) diff --git a/vibe/cli/turn_summary/utils.py b/vibe/cli/turn_summary/utils.py index e5a8391..4ff9a8a 100644 --- a/vibe/cli/turn_summary/utils.py +++ b/vibe/cli/turn_summary/utils.py @@ -1,9 +1,7 @@ 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.config import ModelConfig, VibeConfig, resolve_api_key +from vibe.core.llm.backend.factory import create_backend from vibe.core.llm.types import BackendLike NARRATOR_MODEL = ModelConfig( @@ -22,9 +20,11 @@ def create_narrator_backend( 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): + if provider.api_key_env_var and not resolve_api_key(provider.api_key_env_var): return None - backend = BACKEND_FACTORY[provider.backend]( - provider=provider, timeout=config.api_timeout + backend = create_backend( + provider=provider, + timeout=config.api_timeout, + retry_max_elapsed_time=config.api_retry_max_elapsed_time, ) return backend, NARRATOR_MODEL diff --git a/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py b/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py index fc788cb..c18864e 100644 --- a/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +++ b/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py @@ -4,11 +4,11 @@ import asyncio import json from pathlib import Path -from vibe.cli.cache import read_cache, write_cache from vibe.cli.update_notifier.ports.update_cache_repository import ( UpdateCache, UpdateCacheRepository, ) +from vibe.core.cache_store import FileSystemVibeCodeCacheStore, VibeCodeCacheStore from vibe.core.paths import VIBE_HOME _CACHE_SECTION = "update_cache" @@ -18,6 +18,9 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository): def __init__(self, base_path: Path | str | None = None) -> None: self._base_path = Path(base_path) if base_path is not None else VIBE_HOME.path self._cache_file = self._base_path / "cache.toml" + self._cache_store: VibeCodeCacheStore = FileSystemVibeCodeCacheStore( + self._cache_file + ) self._legacy_json = self._base_path / "update_cache.json" self._cached: UpdateCache | None = None self._loaded = False @@ -39,13 +42,14 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository): payload["seen_whats_new_version"] = update_cache.seen_whats_new_version if update_cache.dismissed_version is not None: payload["dismissed_version"] = update_cache.dismissed_version - await asyncio.to_thread(write_cache, self._cache_file, _CACHE_SECTION, payload) + await asyncio.to_thread( + self._cache_store.write_section, _CACHE_SECTION, payload + ) self._cached = update_cache self._loaded = True def _read_section(self) -> dict | None: - cache = read_cache(self._cache_file) - if section := cache.get(_CACHE_SECTION): + if section := self._cache_store.read_section(_CACHE_SECTION): return section try: @@ -54,10 +58,8 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository): return None if isinstance(data, dict): - write_cache( - self._cache_file, - _CACHE_SECTION, - {k: v for k, v in data.items() if v is not None}, + self._cache_store.write_section( + _CACHE_SECTION, {k: v for k, v in data.items() if v is not None} ) return data diff --git a/vibe/cli/update_notifier/update.py b/vibe/cli/update_notifier/update.py index 3542635..06ad82e 100644 --- a/vibe/cli/update_notifier/update.py +++ b/vibe/cli/update_notifier/update.py @@ -122,12 +122,14 @@ async def get_update_if_available( current_version: str, update_cache_repository: UpdateCacheRepository, get_current_timestamp: Callable[[], int] = lambda: int(time.time()), + *, + force_check: bool = False, ) -> UpdateAvailability | None: current = _parse_version(current_version) if current is None: return None - if update_cache := await update_cache_repository.get(): + if not force_check and (update_cache := await update_cache_repository.get()): if _is_cache_fresh(update_cache, get_current_timestamp): return _get_cached_update_if_any(update_cache, current) diff --git a/vibe/cli/vscode_extension_promo/adapters/filesystem_repository.py b/vibe/cli/vscode_extension_promo/adapters/filesystem_repository.py index 3714ced..3b8ea13 100644 --- a/vibe/cli/vscode_extension_promo/adapters/filesystem_repository.py +++ b/vibe/cli/vscode_extension_promo/adapters/filesystem_repository.py @@ -3,11 +3,11 @@ from __future__ import annotations import asyncio from pathlib import Path -from vibe.cli.cache import read_cache, write_cache from vibe.cli.vscode_extension_promo._port import ( VscodeExtensionPromoRepository, VscodeExtensionPromoState, ) +from vibe.core.cache_store import FileSystemVibeCodeCacheStore, VibeCodeCacheStore from vibe.core.paths import VIBE_HOME _CACHE_SECTION = "vscode_extension_promo" @@ -17,6 +17,9 @@ class FileSystemVscodeExtensionPromoRepository(VscodeExtensionPromoRepository): def __init__(self, base_path: Path | str | None = None) -> None: self._base_path = Path(base_path) if base_path is not None else VIBE_HOME.path self._cache_file = self._base_path / "cache.toml" + self._cache_store: VibeCodeCacheStore = FileSystemVibeCodeCacheStore( + self._cache_file + ) async def get(self) -> VscodeExtensionPromoState | None: data = await asyncio.to_thread(self._read_section) @@ -29,15 +32,13 @@ class FileSystemVscodeExtensionPromoRepository(VscodeExtensionPromoRepository): async def set(self, state: VscodeExtensionPromoState) -> None: await asyncio.to_thread( - write_cache, - self._cache_file, + self._cache_store.write_section, _CACHE_SECTION, {"shown_count": state.shown_count}, ) def _read_section(self) -> dict | None: - cache = read_cache(self._cache_file) - section = cache.get(_CACHE_SECTION) - if isinstance(section, dict): + section = self._cache_store.read_section(_CACHE_SECTION) + if section: return section return None diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index 7b1a41b..83e3729 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -8,7 +8,6 @@ from enum import StrEnum, auto from functools import wraps from http import HTTPStatus import inspect -import os from pathlib import Path import threading from threading import Thread @@ -22,8 +21,9 @@ from pydantic import BaseModel from vibe.core.agent_loop_hooks import AgentLoopHooksMixin from vibe.core.agents.manager import AgentManager from vibe.core.agents.models import AgentProfile, BuiltinAgentName +from vibe.core.cache_store import InMemoryVibeCodeCacheStore, VibeCodeCacheStore from vibe.core.compaction import collect_prior_user_messages, render_compaction_context -from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig +from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig, resolve_api_key from vibe.core.experiments import ExperimentManager from vibe.core.experiments.client import RemoteEvalClient from vibe.core.experiments.session import ( @@ -32,7 +32,7 @@ from vibe.core.experiments.session import ( ) from vibe.core.hooks.manager import HooksManager from vibe.core.hooks.models import HookConfigResult, HookEvent -from vibe.core.llm.backend.factory import BACKEND_FACTORY +from vibe.core.llm.backend.factory import create_backend from vibe.core.llm.exceptions import BackendError from vibe.core.llm.format import ( APIToolFormatHandler, @@ -91,7 +91,7 @@ from vibe.core.tools.base import ( ) from vibe.core.tools.connectors import ConnectorRegistry from vibe.core.tools.manager import ToolManager -from vibe.core.tools.mcp import MCPRegistry +from vibe.core.tools.mcp import MCPConnectionPool, MCPRegistry from vibe.core.tools.mcp_sampling import MCPSamplingHandler from vibe.core.tools.permissions import ( ApprovedRule, @@ -128,6 +128,7 @@ from vibe.core.types import ( ToolCallEvent, ToolResultEvent, ToolStreamEvent, + UserDisplayContentMetadata, UserInputCallback, UserMessageEvent, ) @@ -287,9 +288,11 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 hook_config_result: HookConfigResult | None = None, permission_store: PermissionStore | None = None, mcp_registry: MCPRegistry | None = None, + cache_store: VibeCodeCacheStore | None = None, ) -> None: self._base_config = config self._headless = headless + self.cache_store = cache_store or InMemoryVibeCodeCacheStore() self._defer_heavy_init = defer_heavy_init self._deferred_init_thread: threading.Thread | None = None @@ -303,6 +306,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 self._permission_store = permission_store or PermissionStore() self.mcp_registry = mcp_registry or MCPRegistry() + self._mcp_pool = MCPConnectionPool() self.connector_registry = self._create_connector_registry() self.agent_manager = AgentManager( lambda: self._base_config, @@ -313,7 +317,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 lambda: self.config, mcp_registry=self.mcp_registry, connector_registry=self.connector_registry, - defer_mcp=defer_heavy_init, + defer_mcp=True, permission_getter=self._permission_store.get_tool_permission, ) self.skill_manager = SkillManager(lambda: self.config) @@ -346,17 +350,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 init_scratchpad(self.session_id) if not is_subagent else None ) - system_prompt = get_universal_system_prompt( - self.tool_manager, - self.config, - self.skill_manager, - self.agent_manager, - include_git_status=not defer_heavy_init, - scratchpad_dir=self.scratchpad_dir, - headless=self._headless, - ) - system_message = LLMMessage(role=Role.system, content=system_prompt) - self.messages = MessageList(initial=[system_message], observer=message_observer) + self.messages = MessageList(initial=[], observer=message_observer) self.stats = AgentStats() self.approval_callback: ApprovalCallback | None = None @@ -414,6 +408,10 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 if defer_heavy_init: self._start_deferred_init() + else: + self._complete_init() + if err := self._init_error: + raise err def _start_deferred_init(self) -> threading.Thread: """Spawn a daemon thread that finishes deferred heavy I/O once.""" @@ -444,16 +442,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 """ try: self.tool_manager.integrate_all(raise_on_mcp_failure=True) - system_prompt = get_universal_system_prompt( - self.tool_manager, - self.config, - self.skill_manager, - self.agent_manager, - scratchpad_dir=self.scratchpad_dir, - headless=self._headless, - experiment_manager=self.experiment_manager, - ) - self.messages.update_system_prompt(system_prompt) + self.messages.update_system_prompt(self._build_system_prompt(), notify=True) except Exception as exc: self._init_error = exc @@ -496,6 +485,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 def refresh_config(self) -> None: self._base_config = VibeConfig.load() self.agent_manager.invalidate_config() + self.mcp_registry.sync_active_servers(self.config.mcp_servers) def _drain_pending_injections(self) -> bool: if not self._pending_injected_messages: @@ -616,6 +606,8 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 task.cancel() with contextlib.suppress(BaseException): await task + with contextlib.suppress(Exception): + await self._mcp_pool.aclose() with contextlib.suppress(Exception): await self.backend.__aexit__(None, None, None) with contextlib.suppress(Exception): @@ -630,17 +622,15 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 return None api_key_env = provider.api_key_env_var or "MISTRAL_API_KEY" - api_key = os.getenv(api_key_env, "") + api_key = resolve_api_key(api_key_env) or "" if not api_key: return None server_url = get_server_url_from_api_base(provider.api_base) return ConnectorRegistry(api_key=api_key, server_url=server_url) - @requires_init - async def refresh_system_prompt(self) -> None: - """Rebuild and replace the system prompt with current tool/skill state.""" - system_prompt = get_universal_system_prompt( + def _build_system_prompt(self) -> str: + return get_universal_system_prompt( self.tool_manager, self.config, self.skill_manager, @@ -649,12 +639,19 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 headless=self._headless, experiment_manager=self.experiment_manager, ) - self.messages.update_system_prompt(system_prompt) + + @requires_init + async def refresh_system_prompt(self) -> None: + """Rebuild and replace the system prompt with current tool/skill state.""" + self.messages.update_system_prompt(self._build_system_prompt()) def _select_backend(self) -> BackendLike: provider = self.config.get_active_provider() - timeout = self.config.api_timeout - return BACKEND_FACTORY[provider.backend](provider=provider, timeout=timeout) + return create_backend( + provider=provider, + timeout=self.config.api_timeout, + retry_max_elapsed_time=self.config.api_retry_max_elapsed_time, + ) async def _save_messages(self) -> None: await self.session_logger.save_interaction( @@ -702,6 +699,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 *, auto_title: str | None = None, images: list[ImageAttachment] | None = None, + user_display_content: UserDisplayContentMetadata | None = None, ) -> AsyncGenerator[BaseEvent, None]: try: active_model = self.config.get_active_model() @@ -719,6 +717,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 client_message_id=client_message_id, auto_title=auto_title, images=images, + user_display_content=user_display_content, ): yield event @@ -937,12 +936,14 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 *, auto_title: str | None = None, images: list[ImageAttachment] | None = None, + user_display_content: UserDisplayContentMetadata | None = None, ) -> AsyncGenerator[BaseEvent]: user_message = LLMMessage( role=Role.user, content=user_msg, message_id=client_message_id, images=images or None, + user_display_content=user_display_content, ) self.messages.append(user_message) self.stats.steps += 1 @@ -1363,6 +1364,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 permission_store=self._permission_store, hook_config_result=self._hook_config_result, session_id=self.session_id, + mcp_pool=self._mcp_pool, terminal_emulator=self.terminal_emulator, ), **tool_call.args_dict, @@ -1785,6 +1787,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 terminal_emulator=self.terminal_emulator, defer_heavy_init=True, hook_config_result=self._hook_config_result, + cache_store=self.cache_store, ) forked.session_id = generate_session_id(suffix=extract_suffix(self.session_id)) forked.parent_session_id = self.session_id @@ -1982,17 +1985,7 @@ class AgentLoop(AgentLoopHooksMixin): # noqa: PLR0904 ) self.skill_manager = SkillManager(lambda: self.config) - new_system_prompt = get_universal_system_prompt( - self.tool_manager, - self.config, - self.skill_manager, - self.agent_manager, - scratchpad_dir=self.scratchpad_dir, - headless=self._headless, - experiment_manager=self.experiment_manager, - ) - - self.messages.update_system_prompt(new_system_prompt) + self.messages.update_system_prompt(self._build_system_prompt()) if len(self.messages) == 1: self.stats.reset_context_state() diff --git a/vibe/core/auth/mcp_oauth.py b/vibe/core/auth/mcp_oauth.py index 087e8b1..73e37b9 100644 --- a/vibe/core/auth/mcp_oauth.py +++ b/vibe/core/auth/mcp_oauth.py @@ -10,6 +10,7 @@ import anyio.to_thread import httpx import keyring import keyring.backends.fail +import keyring.errors from mcp.client.auth import ( OAuthClientProvider, OAuthFlowError, @@ -101,6 +102,13 @@ async def _kr_set(username: str, value: str) -> None: await anyio.to_thread.run_sync(keyring.set_password, _SERVICE, username, value) +async def _kr_delete(username: str) -> None: + try: + await anyio.to_thread.run_sync(keyring.delete_password, _SERVICE, username) + except keyring.errors.PasswordDeleteError: + pass + + class Fingerprint(BaseModel): """Config-drift detection marker for OAuth MCP servers. @@ -143,13 +151,23 @@ class Fingerprint(BaseModel): async def save(self, alias: str) -> None: await _kr_set(_kr_username(alias, "fingerprint"), self.model_dump_json()) + @classmethod + async def delete(cls, alias: str) -> None: + await _kr_delete(_kr_username(alias, "fingerprint")) + class KeyringTokenStorage(TokenStorage): - def __init__(self, alias: str) -> None: + def __init__( + self, + alias: str, + *, + fallback_client_info: OAuthClientInformationFull | None = None, + ) -> None: backend = keyring.get_keyring() if isinstance(backend, keyring.backends.fail.Keyring): raise MCPOAuthHeadlessError(server_alias=alias) self._alias = alias + self._fallback_client_info = fallback_client_info async def get_tokens(self) -> OAuthToken | None: raw = await _kr_get(_kr_username(self._alias, "tokens")) @@ -160,10 +178,13 @@ class KeyringTokenStorage(TokenStorage): async def set_tokens(self, tokens: OAuthToken) -> None: await _kr_set(_kr_username(self._alias, "tokens"), tokens.model_dump_json()) + async def delete_tokens(self) -> None: + await _kr_delete(_kr_username(self._alias, "tokens")) + async def get_client_info(self) -> OAuthClientInformationFull | None: raw = await _kr_get(_kr_username(self._alias, "client_info")) if raw is None: - return None + return self._fallback_client_info return OAuthClientInformationFull.model_validate_json(raw) async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: @@ -171,6 +192,9 @@ class KeyringTokenStorage(TokenStorage): _kr_username(self._alias, "client_info"), client_info.model_dump_json() ) + async def delete_client_info(self) -> None: + await _kr_delete(_kr_username(self._alias, "client_info")) + _LOGO_SVG: Final = ( '<svg class="mark" viewBox="0 0 162 162" xmlns="http://www.w3.org/2000/svg" ' @@ -392,10 +416,23 @@ def build_oauth_provider( client_metadata_url = ( str(auth.client_metadata_url) if auth.client_metadata_url else None ) + fallback_client_info = None + if auth.client_id: + fallback_client_info = OAuthClientInformationFull( + client_id=auth.client_id, + redirect_uris=[redirect_uri], + scope=scope, + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + token_endpoint_auth_method="none", + client_name=_CLIENT_NAME, + ) return OAuthClientProvider( server_url=server.url, client_metadata=metadata, - storage=KeyringTokenStorage(alias=server.name), + storage=KeyringTokenStorage( + alias=server.name, fallback_client_info=fallback_client_info + ), redirect_handler=redirect_handler, callback_handler=callback_handler, client_metadata_url=client_metadata_url, @@ -420,8 +457,6 @@ async def perform_oauth_login( auth=provider, timeout=_LOGIN_TIMEOUT_SECONDS, verify=build_ssl_context() ) as client: await client.get(server.url) - except OAuthTokenError as exc: + except (OAuthTokenError, OAuthFlowError) as exc: raise MCPOAuthLoginFailed(server_alias=server.name, reason=str(exc)) from exc - except OAuthFlowError: - raise await Fingerprint.compute(server).save(server.name) diff --git a/vibe/core/cache_store.py b/vibe/core/cache_store.py new file mode 100644 index 0000000..8977a98 --- /dev/null +++ b/vibe/core/cache_store.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from pathlib import Path +import tomllib +from typing import Any, Protocol + +import tomli_w + +from vibe.core.logger import logger +from vibe.core.paths import CACHE_FILE + +__all__ = [ + "FileSystemVibeCodeCacheStore", + "InMemoryVibeCodeCacheStore", + "VibeCodeCacheStore", +] + + +class VibeCodeCacheStore(Protocol): + def read_section(self, section: str) -> dict[str, Any]: ... + + def write_section(self, section: str, data: dict[str, Any]) -> None: ... + + +class InMemoryVibeCodeCacheStore: + def __init__(self) -> None: + self._sections: dict[str, dict[str, Any]] = {} + + def read_section(self, section: str) -> dict[str, Any]: + return dict(self._sections.get(section, {})) + + def write_section(self, section: str, data: dict[str, Any]) -> None: + self._sections.setdefault(section, {}).update(data) + + +class FileSystemVibeCodeCacheStore: + def __init__(self, cache_path: Path | str | None = None) -> None: + self._cache_path = ( + Path(cache_path) if cache_path is not None else CACHE_FILE.path + ) + + def read_section(self, section: str) -> dict[str, Any]: + data = self._read_cache().get(section) + if not isinstance(data, dict): + return {} + return dict(data) + + def write_section(self, section: str, data: dict[str, Any]) -> None: + existing = self._read_cache() + section_data = existing.get(section) + if not isinstance(section_data, dict): + section_data = {} + existing[section] = section_data + section_data.update(data) + try: + self._cache_path.parent.mkdir(parents=True, exist_ok=True) + with self._cache_path.open("wb") as f: + tomli_w.dump(existing, f) + except OSError: + logger.debug( + "Failed to write cache file %s", self._cache_path, exc_info=True + ) + + def _read_cache(self) -> dict[str, Any]: + try: + with self._cache_path.open("rb") as f: + return tomllib.load(f) + except (OSError, tomllib.TOMLDecodeError): + return {} diff --git a/vibe/core/config/__init__.py b/vibe/core/config/__init__.py index dda20aa..898db94 100644 --- a/vibe/core/config/__init__.py +++ b/vibe/core/config/__init__.py @@ -36,24 +36,27 @@ from vibe.core.config._settings import ( TTSProviderConfig, VibeConfig, load_dotenv_values, + resolve_api_key, + resolve_theme_name, ) from vibe.core.config.layer import ( ConfigLayer, ConfigLayerError, + ConfigPatchApplicationError, EmptyLayerError, LayerImplementationError, + LayerNotLoadedError, RawConfig, TrustNotResolvedError, TrustResolutionError, UntrustedLayerError, ) from vibe.core.config.patch import ( - AppendToList, + AddOperationPatch, ConfigPatch, - DeleteField, PatchOp, - RemoveFromList, - SetField, + RemoveOperationPatch, + ReplaceOperationPatch, ) from vibe.core.config.schema import ( ConfigDefinitionError, @@ -84,20 +87,21 @@ __all__ = [ "DEFAULT_VIBE_BASE_URL", "MISSING_CONFIG_FILE_FINGERPRINT", "THINKING_LEVELS", - "AppendToList", + "AddOperationPatch", "ConfigDefinitionError", "ConfigFragment", "ConfigLayer", "ConfigLayerError", "ConfigPatch", + "ConfigPatchApplicationError", "ConfigSchema", "ConnectorConfig", - "DeleteField", "DuplicateMergeMetadataError", "EmptyLayerError", "ExperimentsConfig", "LayerConfigSnapshot", "LayerImplementationError", + "LayerNotLoadedError", "MCPHttp", "MCPOAuth", "MCPServer", @@ -113,9 +117,9 @@ __all__ = [ "ProjectContextConfig", "ProviderConfig", "RawConfig", - "RemoveFromList", + "RemoveOperationPatch", + "ReplaceOperationPatch", "SessionLoggingConfig", - "SetField", "TTSClient", "TTSModelConfig", "TTSProviderConfig", @@ -135,4 +139,6 @@ __all__ = [ "WithShallowMerge", "WithUnionMerge", "load_dotenv_values", + "resolve_api_key", + "resolve_theme_name", ] diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py index ca5ad5c..1ef3eb9 100644 --- a/vibe/core/config/_settings.py +++ b/vibe/core/config/_settings.py @@ -11,6 +11,8 @@ from typing import Annotated, Any, ClassVar, Literal, get_args from urllib.parse import urljoin from dotenv import dotenv_values +import keyring +from keyring.errors import KeyringError from mistralai.client.models import SpeechOutputFormat from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( DEFAULT_TRACES_EXPORT_PATH, @@ -77,7 +79,26 @@ def load_dotenv_values( for key, value in env_vars.items(): if not value: continue - environ.update({key: value}) + if environ.get(key): + # An explicit non-empty process/shell value wins over the .env file. + continue + environ[key] = value + + +_KEYRING_SERVICE = "vibe" + + +def resolve_api_key(env_key: str) -> str | None: + """Resolve an API key value: process/.env environment first, then OS keyring.""" + if not env_key: + return None + value = os.environ.get(env_key) + if value: + return value + try: + return keyring.get_password(_KEYRING_SERVICE, env_key) + except KeyringError: + return None class MissingAPIKeyError(RuntimeError): @@ -447,6 +468,7 @@ THINKING_LEVELS: list[str] = list(get_args(ThinkingLevel)) DEFAULT_AUTO_COMPACT_THRESHOLD = 200_000 DEFAULT_API_TIMEOUT = 720.0 +DEFAULT_API_RETRY_MAX_ELAPSED_TIME = 300.0 class ModelConfig(BaseModel): @@ -505,9 +527,6 @@ class OtelSpanExporterConfig(BaseModel): MISTRAL_OTEL_PATH = "/telemetry" DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai" -DEFAULT_VIBE_CODE_WORKFLOW_ID = "__shared-nuage-workflow" -DEFAULT_VIBE_CODE_TASK_QUEUE = "shared-vibe-nuage" - DEFAULT_PROVIDERS = [ ProviderConfig( name="mistral", @@ -586,6 +605,15 @@ DEFAULT_TTS_MODELS = [DEFAULT_ACTIVE_TTS_MODEL_CONFIG] DEFAULT_THEME = "ansi-dark" +def resolve_theme_name(value: Any) -> str: + if not isinstance(value, str) or not value: + return DEFAULT_THEME + if value not in BUILTIN_THEMES: + logger.warning("Unknown theme=%s; falling back to %s", value, DEFAULT_THEME) + return DEFAULT_THEME + return value + + class VibeConfig(BaseSettings): active_model: str = DEFAULT_ACTIVE_MODEL_CONFIG.alias vim_keybindings: bool = False @@ -614,16 +642,13 @@ class VibeConfig(BaseSettings): enable_notifications: bool = True enable_system_trust_store: bool = False api_timeout: float = DEFAULT_API_TIMEOUT + api_retry_max_elapsed_time: float = DEFAULT_API_RETRY_MAX_ELAPSED_TIME auto_compact_threshold: int = DEFAULT_AUTO_COMPACT_THRESHOLD vibe_code_enabled: bool = Field(default=True, exclude=True) - vibe_code_base_url: str = Field(default=DEFAULT_MISTRAL_SERVER_URL, exclude=True) vibe_code_sessions_base_url: str = Field( default="https://chat.mistral.ai", exclude=True ) - vibe_code_workflow_id: str = Field( - default=DEFAULT_VIBE_CODE_WORKFLOW_ID, exclude=True - ) vibe_code_api_key_env_var: str = Field( default=DEFAULT_MISTRAL_API_ENV_KEY, exclude=True ) @@ -771,7 +796,7 @@ class VibeConfig(BaseSettings): @property def vibe_code_api_key(self) -> str: - return os.getenv(self.vibe_code_api_key_env_var, "") + return resolve_api_key(self.vibe_code_api_key_env_var) or "" @property def otel_span_exporter_config(self) -> OtelSpanExporterConfig | None: @@ -801,7 +826,7 @@ class VibeConfig(BaseSettings): traces_export_path, ) - if not (api_key := os.getenv(api_key_env)): + if not (api_key := resolve_api_key(api_key_env)): logger.warning( "OTEL tracing enabled but %s is not set; skipping.", api_key_env ) @@ -925,10 +950,12 @@ class VibeConfig(BaseSettings): @model_validator(mode="after") def _apply_global_auto_compact_threshold(self) -> VibeConfig: self.models = [ - model - if "auto_compact_threshold" in model.model_fields_set - else model.model_copy( - update={"auto_compact_threshold": self.auto_compact_threshold} + ( + model + if "auto_compact_threshold" in model.model_fields_set + else model.model_copy( + update={"auto_compact_threshold": self.auto_compact_threshold} + ) ) for model in self.models ] @@ -957,7 +984,7 @@ class VibeConfig(BaseSettings): try: provider = self.get_active_provider() api_key_env = provider.api_key_env_var - if api_key_env and not os.getenv(api_key_env): + if api_key_env and not resolve_api_key(api_key_env): raise MissingAPIKeyError(api_key_env, provider.name) except ValueError: pass @@ -966,14 +993,7 @@ class VibeConfig(BaseSettings): @field_validator("theme", mode="before") @classmethod def _validate_theme(cls, v: Any) -> str: - if not isinstance(v, str) or not v: - return DEFAULT_THEME - if v not in BUILTIN_THEMES: - logger.warning( - "Unknown theme=%s in config; falling back to %s", v, DEFAULT_THEME - ) - return DEFAULT_THEME - return v + return resolve_theme_name(v) @field_validator("tool_paths", mode="before") @classmethod diff --git a/vibe/core/config/fingerprint.py b/vibe/core/config/fingerprint.py index 4cf9a06..1b7e283 100644 --- a/vibe/core/config/fingerprint.py +++ b/vibe/core/config/fingerprint.py @@ -17,17 +17,17 @@ from vibe.core.config.types import ConcurrencyConflictError def capture_stable_file(path: Path) -> Iterator[tuple[IO[bytes], str]]: """Yield a file and fingerprint, raising if the path changes before exit.""" with path.open("rb") as file: - before = _create_file_fingerprint(file) + before = create_file_fingerprint(file) yield file, before with path.open("rb") as file: - after = _create_file_fingerprint(file) + after = create_file_fingerprint(file) if after != before: raise ConcurrencyConflictError(expected_fp=before, actual_fp=after) -def _create_file_fingerprint(file: IO) -> str: +def create_file_fingerprint(file: IO) -> str: """Return an opaque token representing the current state of a file.""" stat = os.fstat(file.fileno()) return f"{stat.st_dev}:{stat.st_ino}:{stat.st_mtime_ns}:{stat.st_size}" diff --git a/vibe/core/config/layer.py b/vibe/core/config/layer.py index 9e165a5..7e4b238 100644 --- a/vibe/core/config/layer.py +++ b/vibe/core/config/layer.py @@ -2,13 +2,16 @@ from __future__ import annotations from abc import ABC, abstractmethod import asyncio -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any -from pydantic import BaseModel, ConfigDict +from jsonpatch import JsonPatchException, apply_patch +from jsonpointer import JsonPointerException +from pydantic import BaseModel, ConfigDict, ValidationError from vibe.core.config.patch import ConfigPatch from vibe.core.config.types import ( + MISSING_CONFIG_FILE_FINGERPRINT, ConcurrencyConflictError, ConflictStrategy, LayerConfigSnapshot, @@ -52,6 +55,22 @@ class TrustNotResolvedError(ConfigLayerError): ) +class LayerNotLoadedError(ConfigLayerError): + """Raised when a layer operation requires cached data and fingerprint.""" + + def __init__(self, layer_name: str) -> None: + super().__init__( + layer_name, f"Layer '{layer_name}' must be loaded before applying patches" + ) + + +class ConfigPatchApplicationError(ConfigLayerError): + """Raised when a patch cannot be applied to cached layer data.""" + + def __init__(self, layer_name: str) -> None: + super().__init__(layer_name, f"Layer '{layer_name}': failed to apply patch") + + class TrustResolutionError(ConfigLayerError): """Raised when trust status is not resolvable.""" @@ -102,6 +121,12 @@ class _InvalidateCache: pass +@dataclass(frozen=True, slots=True) +class _ApplyPatch: + patch: ConfigPatch + on_conflict: ConflictStrategy + + class ConfigLayer[S: BaseModel](ABC): """Each layer represents a named source that produces a sparse dictionary of configuration values. @@ -144,6 +169,14 @@ class ConfigLayer[S: BaseModel](ABC): """ return + @abstractmethod + async def _save_to_store(self, _next_config: S) -> str: + """Persist full layer data and return the store's new fingerprint. + + The base class applies patches and validates the result before calling this. + """ + ... + # --- Internal --- async def _notify_trust_change(self, old: bool | None, new: bool | None) -> None: @@ -182,6 +215,10 @@ class ConfigLayer[S: BaseModel](ABC): new_state = await self._handle_load(state, force) case _InvalidateCache(): new_state = await self._handle_invalidate_cache(state) + case _ApplyPatch(patch=patch, on_conflict=on_conflict): + new_state = await self._handle_apply_patch( + state, patch, on_conflict + ) case _: raise NotImplementedError(f"Unknown action: {action!r}") @@ -259,6 +296,42 @@ class ConfigLayer[S: BaseModel](ABC): async def _handle_invalidate_cache(self, state: _LayerState[S]) -> _LayerState[S]: return _LayerState(is_trusted=state.is_trusted, data=None, fingerprint=None) + async def _handle_apply_patch( + self, state: _LayerState[S], patch: ConfigPatch, on_conflict: ConflictStrategy + ) -> _LayerState[S]: + + if ( + state.data is None + or state.fingerprint is None + or state.fingerprint == MISSING_CONFIG_FILE_FINGERPRINT + ): + raise LayerNotLoadedError(self.name) + + match on_conflict: + case ConflictStrategy.CANCEL: + if patch.fingerprint != state.fingerprint: + raise ConcurrencyConflictError( + expected_fp=patch.fingerprint, actual_fp=state.fingerprint + ) + case ConflictStrategy.REPLACE: + pass + case _: + raise ValueError(f"Unsupported conflict strategy: {on_conflict!r}") + + try: + new_data = apply_patch(state.data.model_dump(), patch.to_json_patch()) + validated_new_data = self.validate_output(new_data) + except (JsonPatchException, JsonPointerException, ValidationError) as e: + raise ConfigPatchApplicationError(self.name) from e + + try: + new_fingerprint = await self._save_to_store(validated_new_data) + except NotImplementedError: + raise + except Exception as e: + raise LayerImplementationError(self.name, "_save_to_store") from e + return replace(state, data=validated_new_data, fingerprint=new_fingerprint) + # --- Public --- @property @@ -317,7 +390,7 @@ class ConfigLayer[S: BaseModel](ABC): on_conflict: ConflictStrategy = ConflictStrategy.CANCEL, ) -> None: """Persist a patch to this layer's backing store.""" - raise NotImplementedError + await self._dispatch(_ApplyPatch(patch=patch, on_conflict=on_conflict)) def validate_output(self, data: dict[str, Any]) -> S: """Validate *data* against ``output_schema``.""" diff --git a/vibe/core/config/layers/environment.py b/vibe/core/config/layers/environment.py index 496c673..edc97a0 100644 --- a/vibe/core/config/layers/environment.py +++ b/vibe/core/config/layers/environment.py @@ -44,5 +44,7 @@ class EnvironmentLayer(ConfigLayer[RawConfig]): fingerprint = create_dict_fingerprint(data) return LayerConfigSnapshot(data=data, fingerprint=fingerprint) - async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None: - raise NotImplementedError("EnvironmentLayer.apply() is not implemented (M2)") + async def _save_to_store(self, _next_config: RawConfig) -> str: + raise NotImplementedError( + "EnvironmentLayer patch persistence is not implemented yet" + ) diff --git a/vibe/core/config/layers/overrides.py b/vibe/core/config/layers/overrides.py index c826a82..7b5980d 100644 --- a/vibe/core/config/layers/overrides.py +++ b/vibe/core/config/layers/overrides.py @@ -5,8 +5,7 @@ from typing import Any from vibe.core.config.fingerprint import create_dict_fingerprint from vibe.core.config.layer import ConfigLayer, RawConfig -from vibe.core.config.patch import ConfigPatch -from vibe.core.config.types import ConflictStrategy, LayerConfigSnapshot +from vibe.core.config.types import LayerConfigSnapshot class OverridesLayer(ConfigLayer[RawConfig]): @@ -28,10 +27,7 @@ class OverridesLayer(ConfigLayer[RawConfig]): fingerprint = create_dict_fingerprint(data) return LayerConfigSnapshot(data=data, fingerprint=fingerprint) - async def apply( - self, - patch: ConfigPatch, - *, - on_conflict: ConflictStrategy = ConflictStrategy.CANCEL, - ) -> None: - raise NotImplementedError("OverridesLayer.apply() is not implemented (M2)") + async def _save_to_store(self, _next_config: RawConfig) -> str: + raise NotImplementedError( + "OverridesLayer patch persistence is not implemented yet" + ) diff --git a/vibe/core/config/layers/project.py b/vibe/core/config/layers/project.py index 0598031..ce11dc9 100644 --- a/vibe/core/config/layers/project.py +++ b/vibe/core/config/layers/project.py @@ -6,12 +6,7 @@ import tomllib from vibe.core.config.fingerprint import capture_stable_file from vibe.core.config.layer import ConfigLayer, RawConfig -from vibe.core.config.patch import ConfigPatch -from vibe.core.config.types import ( - EMPTY_CONFIG_SNAPSHOT, - ConflictStrategy, - LayerConfigSnapshot, -) +from vibe.core.config.types import EMPTY_CONFIG_SNAPSHOT, LayerConfigSnapshot from vibe.core.paths._vibe_home import VIBE_HOME from vibe.core.trusted_folders import trusted_folders_manager @@ -73,13 +68,10 @@ class ProjectConfigLayer(ConfigLayer[RawConfig]): await super().revoke_trust() - async def apply( - self, - patch: ConfigPatch, - *, - on_conflict: ConflictStrategy = ConflictStrategy.CANCEL, - ) -> None: - raise NotImplementedError("ProjectConfigLayer.apply() is not implemented (M2)") + async def _save_to_store(self, _next_config: RawConfig) -> str: + raise NotImplementedError( + "ProjectConfigLayer patch persistence is not implemented yet" + ) async def _find_config_file(self) -> None: async with self._find_lock: diff --git a/vibe/core/config/layers/user.py b/vibe/core/config/layers/user.py index 4e14bc8..3919275 100644 --- a/vibe/core/config/layers/user.py +++ b/vibe/core/config/layers/user.py @@ -1,16 +1,15 @@ from __future__ import annotations +import os from pathlib import Path +import tempfile import tomllib -from vibe.core.config.fingerprint import capture_stable_file +import tomli_w + +from vibe.core.config.fingerprint import capture_stable_file, create_file_fingerprint from vibe.core.config.layer import ConfigLayer, RawConfig -from vibe.core.config.patch import ConfigPatch -from vibe.core.config.types import ( - EMPTY_CONFIG_SNAPSHOT, - ConflictStrategy, - LayerConfigSnapshot, -) +from vibe.core.config.types import EMPTY_CONFIG_SNAPSHOT, LayerConfigSnapshot from vibe.core.paths._vibe_home import VIBE_HOME @@ -37,10 +36,29 @@ class UserConfigLayer(ConfigLayer[RawConfig]): return LayerConfigSnapshot(data=data, fingerprint=fingerprint) - async def apply( - self, - patch: ConfigPatch, - *, - on_conflict: ConflictStrategy = ConflictStrategy.CANCEL, - ) -> None: - raise NotImplementedError("UserConfigLayer.apply() is not implemented (M2)") + async def _save_to_store(self, next_config: RawConfig) -> str: + if not self._path.exists(): + raise FileNotFoundError(self._path) + + tmp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + dir=self._path.parent, + prefix=f".{self._path.name}.", + suffix=".tmp", + delete=False, + ) as tmp_file: + tmp_path = Path(tmp_file.name) + tomli_w.dump(next_config.model_dump(), tmp_file) + tmp_file.flush() # Flush Python buffers. + os.fsync(tmp_file.fileno()) # Flush OS buffers. + fingerprint = create_file_fingerprint(tmp_file) + + tmp_path.replace(self._path) + tmp_path = None + finally: + if tmp_path is not None: + tmp_path.unlink(missing_ok=True) + + return fingerprint diff --git a/vibe/core/config/patch.py b/vibe/core/config/patch.py index 172f65a..88b400d 100644 --- a/vibe/core/config/patch.py +++ b/vibe/core/config/patch.py @@ -1,11 +1,14 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any +from abc import ABC, abstractmethod +from typing import Annotated, Any + +from jsonpointer import JsonPointer, JsonPointerException +from pydantic import AfterValidator, BaseModel, ConfigDict class ConfigPatch: - """Declarative, storage-agnostic description of a config delta.""" + """A storage-agnostic set of config changes expressed as JSON Patch operations.""" def __init__( self, *operations: PatchOp, fingerprint: str, reason: str = "" @@ -19,83 +22,75 @@ class ConfigPatch: self.operations.extend(operations) return self + def to_json_patch(self) -> list[dict[str, Any]]: + return [op.to_json_patch() for op in self.operations] + def describe(self) -> list[str]: """Human-readable summary of each operation.""" - lines: list[str] = [] - for op in self.operations: - match op: - case SetField(key=key, value=value): - lines.append(f"set {key!r} = {value!r}") - case AppendToList(key=key, items=items): - lines.append(f"append to {key!r}: {list(items)!r}") - case RemoveFromList(key=key, values=values): - lines.append(f"remove from {key!r}: {list(values)!r}") - case DeleteField(key=key): - lines.append(f"delete {key!r}") - return lines + return [op.describe() for op in self.operations] -@dataclass(frozen=True, slots=True) -class SetField: - """Set a top-level or nested field to a value.""" +class _OperationPatch(BaseModel, ABC): + model_config = ConfigDict(frozen=True, extra="forbid") + + @staticmethod + def _validate_json_pointer(path: str) -> str: + try: + JsonPointer(path) + except JsonPointerException as e: + raise ValueError("path must be a valid JSON Pointer") from e + + return path + + path: Annotated[str, AfterValidator(_validate_json_pointer)] + + @abstractmethod + def to_json_patch(self) -> dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def describe(self) -> str: + raise NotImplementedError + + +class AddOperationPatch(_OperationPatch): + """Add a value at a JSON Pointer path.""" + + model_config = ConfigDict(frozen=True, extra="forbid") - key: str value: Any - def __post_init__(self) -> None: - _validate_key_path(self.key) + def to_json_patch(self) -> dict[str, Any]: + return {"op": "add", "path": self.path, "value": self.value} + + def describe(self) -> str: + return f"add {self.path!r} = {self.value!r}" -@dataclass(frozen=True, slots=True) -class AppendToList: - """Append items to a list field.""" +class ReplaceOperationPatch(_OperationPatch): + """Replace the existing value at a JSON Pointer path.""" - key: str - items: tuple[Any, ...] + model_config = ConfigDict(frozen=True, extra="forbid") - def __post_init__(self) -> None: - _validate_key_path(self.key) - _validate_tuple_value("AppendToList.items", self.items) + value: Any + + def to_json_patch(self) -> dict[str, Any]: + return {"op": "replace", "path": self.path, "value": self.value} + + def describe(self) -> str: + return f"replace {self.path!r} = {self.value!r}" -@dataclass(frozen=True, slots=True) -class RemoveFromList: - """Remove items from a list field by value.""" +class RemoveOperationPatch(_OperationPatch): + """Remove the existing value at a JSON Pointer path.""" - key: str - values: tuple[Any, ...] + model_config = ConfigDict(frozen=True, extra="forbid") - def __post_init__(self) -> None: - _validate_key_path(self.key) - _validate_tuple_value("RemoveFromList.values", self.values) + def to_json_patch(self) -> dict[str, Any]: + return {"op": "remove", "path": self.path} + + def describe(self) -> str: + return f"remove {self.path!r}" -@dataclass(frozen=True, slots=True) -class DeleteField: - """Remove a field entirely from the config.""" - - key: str - - def __post_init__(self) -> None: - _validate_key_path(self.key) - - -PatchOp = SetField | AppendToList | RemoveFromList | DeleteField - - -def _validate_key_path(key: object) -> None: - if not isinstance(key, str): - raise TypeError( - f"Patch operation key must be a string, got {type(key).__name__}" - ) - if not key: - raise ValueError("Patch operation key must not be empty") - if any(not segment for segment in key.split(".")): - raise ValueError( - "Patch operation key must be a dot-separated path without empty segments" - ) - - -def _validate_tuple_value(field_name: str, value: object) -> None: - if not isinstance(value, tuple): - raise TypeError(f"{field_name} must be a tuple, got {type(value).__name__}") +type PatchOp = AddOperationPatch | ReplaceOperationPatch | RemoveOperationPatch diff --git a/vibe/core/config/vibe_schema.py b/vibe/core/config/vibe_schema.py index c539887..892083c 100644 --- a/vibe/core/config/vibe_schema.py +++ b/vibe/core/config/vibe_schema.py @@ -10,11 +10,11 @@ from vibe.core.config._settings import ( DEFAULT_ACTIVE_MODEL_CONFIG, DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG, DEFAULT_ACTIVE_TTS_MODEL_CONFIG, + DEFAULT_API_RETRY_MAX_ELAPSED_TIME, DEFAULT_API_TIMEOUT, DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONSOLE_BASE_URL, DEFAULT_MISTRAL_API_ENV_KEY, - DEFAULT_MISTRAL_SERVER_URL, DEFAULT_MODELS, DEFAULT_PROVIDERS, DEFAULT_THEME, @@ -23,8 +23,6 @@ from vibe.core.config._settings import ( DEFAULT_TTS_MODELS, DEFAULT_TTS_PROVIDERS, DEFAULT_VIBE_BASE_URL, - DEFAULT_VIBE_CODE_TASK_QUEUE, - DEFAULT_VIBE_CODE_WORKFLOW_ID, ConnectorConfig, ExperimentsConfig, MCPServer, @@ -182,18 +180,10 @@ class VibeConfigSchema(ConfigSchema): # Internal vibe_code_enabled: Annotated[bool, WithReplaceMerge()] = True - vibe_code_base_url: Annotated[str, WithReplaceMerge()] = DEFAULT_MISTRAL_SERVER_URL - vibe_code_workflow_id: Annotated[str, WithReplaceMerge()] = ( - DEFAULT_VIBE_CODE_WORKFLOW_ID - ) - vibe_code_task_queue: Annotated[str | None, WithReplaceMerge()] = ( - DEFAULT_VIBE_CODE_TASK_QUEUE - ) vibe_code_api_key_env_var: Annotated[str, WithReplaceMerge()] = ( DEFAULT_MISTRAL_API_ENV_KEY ) vibe_code_project_name: Annotated[str | None, WithReplaceMerge()] = None - vibe_code_experimental_nuage_enabled: Annotated[bool, WithReplaceMerge()] = False enable_otel: Annotated[bool, WithReplaceMerge()] = False otel_endpoint: Annotated[str, WithReplaceMerge()] = "" console_base_url: Annotated[str, WithReplaceMerge()] = DEFAULT_CONSOLE_BASE_URL @@ -229,6 +219,9 @@ class VibeConfigSchema(ConfigSchema): enable_notifications: Annotated[bool, WithReplaceMerge()] = True enable_system_trust_store: Annotated[bool, WithReplaceMerge()] = False api_timeout: Annotated[float, WithReplaceMerge()] = DEFAULT_API_TIMEOUT + api_retry_max_elapsed_time: Annotated[float, WithReplaceMerge()] = ( + DEFAULT_API_RETRY_MAX_ELAPSED_TIME + ) vibe_base_url: Annotated[str, WithReplaceMerge()] = DEFAULT_VIBE_BASE_URL vibe_code_sessions_base_url: Annotated[str, WithReplaceMerge()] = ( "https://chat.mistral.ai" diff --git a/vibe/core/feedback.py b/vibe/core/feedback.py index d234de4..f7e1ee2 100644 --- a/vibe/core/feedback.py +++ b/vibe/core/feedback.py @@ -3,8 +3,7 @@ from __future__ import annotations import random import time -from vibe.cli.cache import read_cache, write_cache -from vibe.core.paths import CACHE_FILE +from vibe.core.cache_store import VibeCodeCacheStore FEEDBACK_PROBABILITY = 0.2 FEEDBACK_COOLDOWN_SECONDS = 3600 @@ -14,7 +13,11 @@ _LAST_SHOWN_KEY = "last_shown_at" def should_show_feedback( - *, telemetry_active: bool, is_mistral_model: bool, user_message_count: int + *, + telemetry_active: bool, + is_mistral_model: bool, + user_message_count: int, + cache_store: VibeCodeCacheStore, ) -> bool: if not telemetry_active: return False @@ -23,9 +26,7 @@ def should_show_feedback( if user_message_count < MIN_USER_MESSAGES_FOR_FEEDBACK: return False - last_ts = ( - read_cache(CACHE_FILE.path).get(_CACHE_SECTION, {}).get(_LAST_SHOWN_KEY, 0) - ) + last_ts = cache_store.read_section(_CACHE_SECTION).get(_LAST_SHOWN_KEY, 0) if not isinstance(last_ts, int): return False @@ -35,5 +36,5 @@ def should_show_feedback( ) -def record_feedback_asked() -> None: - write_cache(CACHE_FILE.path, _CACHE_SECTION, {_LAST_SHOWN_KEY: int(time.time())}) +def record_feedback_asked(cache_store: VibeCodeCacheStore) -> None: + cache_store.write_section(_CACHE_SECTION, {_LAST_SHOWN_KEY: int(time.time())}) diff --git a/vibe/core/llm/backend/_image.py b/vibe/core/llm/backend/_image.py index 990d908..47342f2 100644 --- a/vibe/core/llm/backend/_image.py +++ b/vibe/core/llm/backend/_image.py @@ -4,7 +4,7 @@ import base64 from functools import lru_cache from pathlib import Path -from vibe.core.types import ImageAttachment +from vibe.core.types import FileImageSource, ImageAttachment, InlineImageSource class ImageReadError(Exception): @@ -23,11 +23,15 @@ def _encode_cached(path_str: str, mtime_ns: int, size: int) -> str: def _encode(att: ImageAttachment) -> str: - try: - stat = att.path.stat() - except OSError as e: - raise ImageReadError(f"Failed to stat image {att.path}: {e}") from e - return _encode_cached(str(att.path), stat.st_mtime_ns, stat.st_size) + match att.source: + case InlineImageSource(data=data): + return data + case FileImageSource(path=path): + try: + stat = path.stat() + except OSError as e: + raise ImageReadError(f"Failed to stat image {path}: {e}") from e + return _encode_cached(str(path), stat.st_mtime_ns, stat.st_size) def to_data_uri(att: ImageAttachment) -> str: diff --git a/vibe/core/llm/backend/anthropic.py b/vibe/core/llm/backend/anthropic.py index ed04eda..43fd0e7 100644 --- a/vibe/core/llm/backend/anthropic.py +++ b/vibe/core/llm/backend/anthropic.py @@ -201,129 +201,6 @@ class AnthropicMapper: stop=_parse_stop_info(data.get("stop_reason"), data.get("stop_details")), ) - def parse_streaming_event( - self, event_type: str, data: dict[str, Any], current_index: int - ) -> tuple[LLMChunk | None, int]: - handler = { - "content_block_start": self._handle_block_start, - "content_block_delta": self._handle_block_delta, - "message_delta": self._handle_message_delta, - "message_start": self._handle_message_start, - }.get(event_type) - if handler is None: - return None, current_index - return handler(data, current_index) - - def _handle_block_start( - self, data: dict[str, Any], current_index: int - ) -> tuple[LLMChunk | None, int]: - block = data.get("content_block", {}) - idx = data.get("index", current_index) - - match block.get("type"): - case "tool_use": - chunk = LLMChunk( - message=LLMMessage( - role=Role.assistant, - tool_calls=[ - ToolCall( - id=block.get("id"), - index=idx, - function=FunctionCall( - name=block.get("name"), arguments="" - ), - ) - ], - ) - ) - return chunk, idx - case "thinking": - chunk = LLMChunk( - message=LLMMessage( - role=Role.assistant, reasoning_content=block.get("thinking", "") - ) - ) - return chunk, idx - case _: - return None, idx - - def _handle_block_delta( - self, data: dict[str, Any], current_index: int - ) -> tuple[LLMChunk | None, int]: - delta = data.get("delta", {}) - idx = data.get("index", current_index) - - match delta.get("type"): - case "text_delta": - chunk = LLMChunk( - message=LLMMessage( - role=Role.assistant, content=delta.get("text", "") - ) - ) - case "thinking_delta": - chunk = LLMChunk( - message=LLMMessage( - role=Role.assistant, reasoning_content=delta.get("thinking", "") - ) - ) - case "signature_delta": - chunk = LLMChunk( - message=LLMMessage( - role=Role.assistant, - reasoning_signature=delta.get("signature", ""), - ) - ) - case "input_json_delta": - chunk = LLMChunk( - message=LLMMessage( - role=Role.assistant, - tool_calls=[ - ToolCall( - index=idx, - function=FunctionCall( - arguments=delta.get("partial_json", "") - ), - ) - ], - ) - ) - case _: - chunk = None - return chunk, idx - - def _handle_message_delta( - self, data: dict[str, Any], current_index: int - ) -> tuple[LLMChunk | None, int]: - usage_data = data.get("usage", {}) - if not usage_data: - return None, current_index - chunk = LLMChunk( - message=LLMMessage(role=Role.assistant), - usage=LLMUsage( - prompt_tokens=0, completion_tokens=usage_data.get("output_tokens", 0) - ), - ) - return chunk, current_index - - def _handle_message_start( - self, data: dict[str, Any], current_index: int - ) -> tuple[LLMChunk | None, int]: - message = data.get("message", {}) - usage_data = message.get("usage", {}) - if not usage_data: - return None, current_index - # Total input tokens = input_tokens + cache_creation + cache_read - total_input_tokens = ( - usage_data.get("input_tokens", 0) - + usage_data.get("cache_creation_input_tokens", 0) - + usage_data.get("cache_read_input_tokens", 0) - ) - chunk = LLMChunk( - message=LLMMessage(role=Role.assistant), - usage=LLMUsage(prompt_tokens=total_input_tokens, completion_tokens=0), - ) - return chunk, current_index - STREAMING_EVENT_TYPES = { "message_start", diff --git a/vibe/core/llm/backend/factory.py b/vibe/core/llm/backend/factory.py index 458d6a1..0643a76 100644 --- a/vibe/core/llm/backend/factory.py +++ b/vibe/core/llm/backend/factory.py @@ -1,7 +1,30 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from vibe.core.llm.backend.generic import GenericBackend from vibe.core.llm.backend.mistral import MistralBackend from vibe.core.types import Backend +if TYPE_CHECKING: + from vibe.core.config import ProviderConfig + from vibe.core.llm.types import BackendLike + + BACKEND_FACTORY = {Backend.MISTRAL: MistralBackend, Backend.GENERIC: GenericBackend} + + +def create_backend( + *, + provider: ProviderConfig, + timeout: float = 720.0, + retry_max_elapsed_time: float = 300.0, +) -> BackendLike: + factory = BACKEND_FACTORY[provider.backend] + if provider.backend == Backend.MISTRAL: + return factory( + provider=provider, + timeout=timeout, + retry_max_elapsed_time=retry_max_elapsed_time, + ) + return factory(provider=provider, timeout=timeout) diff --git a/vibe/core/llm/backend/generic.py b/vibe/core/llm/backend/generic.py index 8006492..0384e1a 100644 --- a/vibe/core/llm/backend/generic.py +++ b/vibe/core/llm/backend/generic.py @@ -2,12 +2,12 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Sequence import json -import os import types from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple import httpx +from vibe.core.config import resolve_api_key from vibe.core.llm.backend._image import to_data_uri as _to_data_uri from vibe.core.llm.backend.anthropic import AnthropicAdapter from vibe.core.llm.backend.base import APIAdapter, PreparedRequest @@ -123,6 +123,7 @@ class OpenAIAdapter(APIAdapter): "reasoning_state", "injected", "images", + "user_display_content", }, ), field_name, @@ -266,11 +267,7 @@ class GenericBackend: extra_headers: dict[str, str] | None = None, metadata: dict[str, str] | None = None, ) -> LLMChunk: - api_key = ( - os.getenv(self._provider.api_key_env_var) - if self._provider.api_key_env_var - else None - ) + api_key = resolve_api_key(self._provider.api_key_env_var) api_style = getattr(self._provider, "api_style", "openai") adapter = _get_adapter(api_style) @@ -334,11 +331,7 @@ class GenericBackend: extra_headers: dict[str, str] | None = None, metadata: dict[str, str] | None = None, ) -> AsyncGenerator[LLMChunk, None]: - api_key = ( - os.getenv(self._provider.api_key_env_var) - if self._provider.api_key_env_var - else None - ) + api_key = resolve_api_key(self._provider.api_key_env_var) api_style = getattr(self._provider, "api_style", "openai") adapter = _get_adapter(api_style) diff --git a/vibe/core/llm/backend/mistral.py b/vibe/core/llm/backend/mistral.py index f9c6f98..ffac635 100644 --- a/vibe/core/llm/backend/mistral.py +++ b/vibe/core/llm/backend/mistral.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Sequence import json -import os import types from typing import TYPE_CHECKING, Literal, NamedTuple, cast @@ -33,6 +32,7 @@ from mistralai.client.models import ( ) from mistralai.client.utils.retries import BackoffStrategy, RetryConfig +from vibe.core.config import resolve_api_key from vibe.core.llm.backend._image import to_data_uri as _to_data_uri from vibe.core.llm.exceptions import BackendErrorBuilder from vibe.core.types import ( @@ -196,16 +196,17 @@ _THINKING_TO_REASONING_EFFORT: dict[str, ReasoningEffortValue] = { class MistralBackend: - def __init__(self, provider: ProviderConfig, timeout: float = 720.0) -> None: + def __init__( + self, + provider: ProviderConfig, + timeout: float = 720.0, + retry_max_elapsed_time: float = 300.0, + ) -> None: self._client: Mistral | None = None self._http_client: httpx.AsyncClient | None = None self._provider = provider self._mapper = MistralMapper() - self._api_key = ( - os.getenv(self._provider.api_key_env_var) - if self._provider.api_key_env_var - else None - ) + self._api_key = resolve_api_key(self._provider.api_key_env_var) reasoning_field = getattr(provider, "reasoning_field_name", "reasoning_content") if reasoning_field != "reasoning_content": @@ -223,16 +224,18 @@ class MistralBackend: ) self._server_url = server_url self._timeout = timeout + self._retry_max_elapsed_time = retry_max_elapsed_time self._retry_config = self._build_retry_config() def _build_retry_config(self) -> RetryConfig: + max_elapsed_time_ms = int(self._retry_max_elapsed_time * 1000) return RetryConfig( strategy="backoff", backoff=BackoffStrategy( initial_interval=500, max_interval=30000, exponent=1.5, - max_elapsed_time=300000, + max_elapsed_time=max_elapsed_time_ms, ), retry_connection_errors=True, ) diff --git a/vibe/core/nuage/__init__.py b/vibe/core/nuage/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/vibe/core/nuage/agent_models.py b/vibe/core/nuage/agent_models.py deleted file mode 100644 index af1ce74..0000000 --- a/vibe/core/nuage/agent_models.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from pydantic import BaseModel, ConfigDict - -_SUBMIT_INPUT_UPDATE_NAME = "__submit_input" - - -class AgentCompletionState(BaseModel): - content: str = "" - reasoning_content: str = "" - - -class InterruptSignal(BaseModel): - prompt: str - - -class ChatInputModel(BaseModel): - model_config = ConfigDict(title="ChatInput", extra="forbid") - message: list[Any] - - -class SubmitInputModel(BaseModel): - task_id: str - input: Any diff --git a/vibe/core/nuage/client.py b/vibe/core/nuage/client.py deleted file mode 100644 index 5efeef1..0000000 --- a/vibe/core/nuage/client.py +++ /dev/null @@ -1,209 +0,0 @@ -from __future__ import annotations - -from collections.abc import AsyncGenerator, Sequence -import json -from typing import Any - -import httpx -from pydantic import BaseModel - -from vibe.core.logger import logger -from vibe.core.nuage.exceptions import ErrorCode, WorkflowsException -from vibe.core.nuage.streaming import StreamEvent, StreamEventsQueryParams -from vibe.core.nuage.workflow import ( - SignalWorkflowResponse, - UpdateWorkflowResponse, - WorkflowExecutionListResponse, - WorkflowExecutionStatus, -) -from vibe.core.utils.http import build_ssl_context -from vibe.core.utils.sse import iter_sse_lines - - -class WorkflowsClient: - def __init__( - self, base_url: str, api_key: str | None = None, timeout: float = 60.0 - ) -> None: - self._base_url = base_url.rstrip("/") - self._api_key = api_key - self._timeout = timeout - self._client: httpx.AsyncClient | None = None - self._owns_client = True - - async def __aenter__(self) -> WorkflowsClient: - headers: dict[str, str] = {} - if self._api_key: - headers["Authorization"] = f"Bearer {self._api_key}" - self._client = httpx.AsyncClient( - timeout=self._timeout, headers=headers, verify=build_ssl_context() - ) - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: Any, - ) -> None: - await self.aclose() - - async def aclose(self) -> None: - if self._owns_client and self._client: - await self._client.aclose() - self._client = None - - @property - def _http_client(self) -> httpx.AsyncClient: - if self._client is None: - headers: dict[str, str] = {} - if self._api_key: - headers["Authorization"] = f"Bearer {self._api_key}" - self._client = httpx.AsyncClient( - timeout=self._timeout, headers=headers, verify=build_ssl_context() - ) - self._owns_client = True - return self._client - - def _api_url(self, endpoint: str) -> str: - return f"{self._base_url}/v1/workflows{endpoint}" - - def _parse_sse_data( - self, raw_data: str, event_type: str | None - ) -> StreamEvent | None: - parsed = json.loads(raw_data) - if event_type == "error" or (isinstance(parsed, dict) and "error" in parsed): - error_msg = ( - parsed.get("error", "Unknown stream error") - if isinstance(parsed, dict) - else str(parsed) - ) - raise WorkflowsException( - message=f"Stream error from server: {error_msg}", - code=ErrorCode.GET_EVENTS_STREAM_ERROR, - ) - return StreamEvent.model_validate(parsed) - - async def stream_events( - self, params: StreamEventsQueryParams - ) -> AsyncGenerator[StreamEvent, None]: - endpoint = "/events/stream" - query = params.model_dump(exclude_none=True) - try: - async with self._http_client.stream( - "GET", self._api_url(endpoint), params=query - ) as response: - response.raise_for_status() - async for event in self._iter_sse_events(response): - yield event - except WorkflowsException: - raise - except Exception as exc: - raise WorkflowsException.from_api_client_error( - exc, - message="Failed to stream events", - code=ErrorCode.GET_EVENTS_STREAM_ERROR, - ) from exc - - async def _iter_sse_events( - self, response: httpx.Response - ) -> AsyncGenerator[StreamEvent, None]: - event_type: str | None = None - async for line in iter_sse_lines(response): - if line == "" or line.startswith(":"): - continue - if line.startswith("event:"): - event_type = line[len("event:") :].strip() - continue - if not line.startswith("data:"): - continue - raw_data = line[len("data:") :].strip() - try: - event = self._parse_sse_data(raw_data, event_type) - if event: - yield event - except WorkflowsException: - raise - except Exception: - logger.warning( - "Failed to parse SSE event", - exc_info=True, - extra={"event_data": raw_data}, - ) - finally: - event_type = None - - async def signal_workflow( - self, execution_id: str, signal_name: str, input_data: BaseModel | None = None - ) -> SignalWorkflowResponse: - endpoint = f"/executions/{execution_id}/signals" - try: - input_data_dict = input_data.model_dump(mode="json") if input_data else {} - request_body = {"name": signal_name, "input": input_data_dict} - response = await self._http_client.post( - self._api_url(endpoint), - json=request_body, - headers={"Content-Type": "application/json"}, - ) - response.raise_for_status() - return SignalWorkflowResponse.model_validate(response.json()) - except WorkflowsException: - raise - except Exception as exc: - raise WorkflowsException.from_api_client_error( - exc, - message="Failed to signal workflow", - code=ErrorCode.POST_EXECUTIONS_SIGNALS_ERROR, - ) from exc - - async def update_workflow( - self, execution_id: str, update_name: str, input_data: BaseModel | None = None - ) -> UpdateWorkflowResponse: - endpoint = f"/executions/{execution_id}/updates" - try: - input_data_dict = input_data.model_dump(mode="json") if input_data else {} - request_body = {"name": update_name, "input": input_data_dict} - response = await self._http_client.post( - self._api_url(endpoint), - json=request_body, - headers={"Content-Type": "application/json"}, - ) - response.raise_for_status() - return UpdateWorkflowResponse.model_validate(response.json()) - except WorkflowsException: - raise - except Exception as exc: - raise WorkflowsException.from_api_client_error( - exc, - message="Failed to update workflow", - code=ErrorCode.POST_EXECUTIONS_UPDATES_ERROR, - ) from exc - - async def get_workflow_runs( - self, - workflow_identifier: str | None = None, - page_size: int = 50, - next_page_token: str | None = None, - status: Sequence[WorkflowExecutionStatus] | None = None, - user_id: str = "current", - ) -> WorkflowExecutionListResponse: - params: dict[str, Any] = {"page_size": page_size, "user_id": user_id} - if workflow_identifier: - params["workflow_identifier"] = workflow_identifier - if next_page_token: - params["next_page_token"] = next_page_token - if status: - params["status"] = status - endpoint = "/runs" - - try: - response = await self._http_client.get( - self._api_url(endpoint), params=params - ) - response.raise_for_status() - return WorkflowExecutionListResponse.model_validate(response.json()) - except Exception as exc: - raise WorkflowsException.from_api_client_error( - exc, - message="Failed to get workflow runs", - code=ErrorCode.GET_EXECUTIONS_ERROR, - ) from exc diff --git a/vibe/core/nuage/events.py b/vibe/core/nuage/events.py deleted file mode 100644 index 6d29e4f..0000000 --- a/vibe/core/nuage/events.py +++ /dev/null @@ -1,227 +0,0 @@ -from __future__ import annotations - -from enum import StrEnum -from typing import Annotated, Any, Literal - -from pydantic import BaseModel, Discriminator, Field, Tag - - -class WorkflowEventType(StrEnum): - WORKFLOW_EXECUTION_COMPLETED = "WORKFLOW_EXECUTION_COMPLETED" - WORKFLOW_EXECUTION_FAILED = "WORKFLOW_EXECUTION_FAILED" - WORKFLOW_EXECUTION_CANCELED = "WORKFLOW_EXECUTION_CANCELED" - CUSTOM_TASK_STARTED = "CUSTOM_TASK_STARTED" - CUSTOM_TASK_IN_PROGRESS = "CUSTOM_TASK_IN_PROGRESS" - CUSTOM_TASK_COMPLETED = "CUSTOM_TASK_COMPLETED" - CUSTOM_TASK_FAILED = "CUSTOM_TASK_FAILED" - CUSTOM_TASK_TIMED_OUT = "CUSTOM_TASK_TIMED_OUT" - CUSTOM_TASK_CANCELED = "CUSTOM_TASK_CANCELED" - - -class JSONPatchBase(BaseModel): - path: str - value: Any = None - - -class JSONPatchAdd(JSONPatchBase): - op: Literal["add"] = "add" - - -class JSONPatchReplace(JSONPatchBase): - op: Literal["replace"] = "replace" - - -class JSONPatchRemove(JSONPatchBase): - op: Literal["remove"] = "remove" - - -class JSONPatchAppend(JSONPatchBase): - op: Literal["append"] = "append" - value: str = "" - - -JSONPatch = Annotated[ - Annotated[JSONPatchAppend, Tag("append")] - | Annotated[JSONPatchAdd, Tag("add")] - | Annotated[JSONPatchReplace, Tag("replace")] - | Annotated[JSONPatchRemove, Tag("remove")], - Discriminator("op"), -] - - -class JSONPatchPayload(BaseModel): - type: Literal["json_patch"] = "json_patch" - value: list[JSONPatch] = Field(default_factory=list) - - -class JSONPayload(BaseModel): - type: Literal["json"] = "json" - value: Any = None - - -Payload = Annotated[ - Annotated[JSONPayload, Tag("json")] - | Annotated[JSONPatchPayload, Tag("json_patch")], - Discriminator("type"), -] - - -class Failure(BaseModel): - message: str - - -class BaseEvent(BaseModel): - event_id: str - event_timestamp: int = 0 - root_workflow_exec_id: str = "" - parent_workflow_exec_id: str | None = None - workflow_exec_id: str = "" - workflow_run_id: str = "" - workflow_name: str = "" - - -class WorkflowExecutionFailedAttributes(BaseModel): - task_id: str = "" - failure: Failure - - -class WorkflowExecutionCanceledAttributes(BaseModel): - task_id: str = "" - reason: str | None = None - - -class WorkflowExecutionCompletedAttributes(BaseModel): - task_id: str = "" - result: JSONPayload = Field(default_factory=lambda: JSONPayload(value=None)) - - -class CustomTaskStartedAttributes(BaseModel): - custom_task_id: str - custom_task_type: str - payload: JSONPayload = Field(default_factory=lambda: JSONPayload(value=None)) - - -class CustomTaskInProgressAttributes(BaseModel): - custom_task_id: str - custom_task_type: str - payload: Payload - - -class CustomTaskCompletedAttributes(BaseModel): - custom_task_id: str - custom_task_type: str - payload: JSONPayload = Field(default_factory=lambda: JSONPayload(value=None)) - - -class CustomTaskFailedAttributes(BaseModel): - custom_task_id: str - custom_task_type: str - failure: Failure - - -class CustomTaskTimedOutAttributes(BaseModel): - custom_task_id: str - custom_task_type: str - timeout_type: str | None = None - - -class CustomTaskCanceledAttributes(BaseModel): - custom_task_id: str - custom_task_type: str - reason: str | None = None - - -class WorkflowExecutionCompleted(BaseEvent): - event_type: Literal[WorkflowEventType.WORKFLOW_EXECUTION_COMPLETED] = ( - WorkflowEventType.WORKFLOW_EXECUTION_COMPLETED - ) - attributes: WorkflowExecutionCompletedAttributes - - -class WorkflowExecutionFailed(BaseEvent): - event_type: Literal[WorkflowEventType.WORKFLOW_EXECUTION_FAILED] = ( - WorkflowEventType.WORKFLOW_EXECUTION_FAILED - ) - attributes: WorkflowExecutionFailedAttributes - - -class WorkflowExecutionCanceled(BaseEvent): - event_type: Literal[WorkflowEventType.WORKFLOW_EXECUTION_CANCELED] = ( - WorkflowEventType.WORKFLOW_EXECUTION_CANCELED - ) - attributes: WorkflowExecutionCanceledAttributes - - -class CustomTaskStarted(BaseEvent): - event_type: Literal[WorkflowEventType.CUSTOM_TASK_STARTED] = ( - WorkflowEventType.CUSTOM_TASK_STARTED - ) - attributes: CustomTaskStartedAttributes - - -class CustomTaskInProgress(BaseEvent): - event_type: Literal[WorkflowEventType.CUSTOM_TASK_IN_PROGRESS] = ( - WorkflowEventType.CUSTOM_TASK_IN_PROGRESS - ) - attributes: CustomTaskInProgressAttributes - - -class CustomTaskCompleted(BaseEvent): - event_type: Literal[WorkflowEventType.CUSTOM_TASK_COMPLETED] = ( - WorkflowEventType.CUSTOM_TASK_COMPLETED - ) - attributes: CustomTaskCompletedAttributes - - -class CustomTaskFailed(BaseEvent): - event_type: Literal[WorkflowEventType.CUSTOM_TASK_FAILED] = ( - WorkflowEventType.CUSTOM_TASK_FAILED - ) - attributes: CustomTaskFailedAttributes - - -class CustomTaskTimedOut(BaseEvent): - event_type: Literal[WorkflowEventType.CUSTOM_TASK_TIMED_OUT] = ( - WorkflowEventType.CUSTOM_TASK_TIMED_OUT - ) - attributes: CustomTaskTimedOutAttributes - - -class CustomTaskCanceled(BaseEvent): - event_type: Literal[WorkflowEventType.CUSTOM_TASK_CANCELED] = ( - WorkflowEventType.CUSTOM_TASK_CANCELED - ) - attributes: CustomTaskCanceledAttributes - - -def _get_event_type_discriminator(v: Any) -> str: - if isinstance(v, dict): - event_type_val = v.get("event_type", "") - if isinstance(event_type_val, WorkflowEventType): - return event_type_val.value - return str(event_type_val) - - event_type_attr = getattr(v, "event_type", "") - if isinstance(event_type_attr, WorkflowEventType): - return event_type_attr.value - return str(event_type_attr) - - -WorkflowEvent = Annotated[ - Annotated[ - WorkflowExecutionCompleted, Tag(WorkflowEventType.WORKFLOW_EXECUTION_COMPLETED) - ] - | Annotated[ - WorkflowExecutionFailed, Tag(WorkflowEventType.WORKFLOW_EXECUTION_FAILED) - ] - | Annotated[ - WorkflowExecutionCanceled, Tag(WorkflowEventType.WORKFLOW_EXECUTION_CANCELED) - ] - | Annotated[CustomTaskStarted, Tag(WorkflowEventType.CUSTOM_TASK_STARTED)] - | Annotated[CustomTaskInProgress, Tag(WorkflowEventType.CUSTOM_TASK_IN_PROGRESS)] - | Annotated[CustomTaskCompleted, Tag(WorkflowEventType.CUSTOM_TASK_COMPLETED)] - | Annotated[CustomTaskFailed, Tag(WorkflowEventType.CUSTOM_TASK_FAILED)] - | Annotated[CustomTaskTimedOut, Tag(WorkflowEventType.CUSTOM_TASK_TIMED_OUT)] - | Annotated[CustomTaskCanceled, Tag(WorkflowEventType.CUSTOM_TASK_CANCELED)], - Discriminator(_get_event_type_discriminator), -] diff --git a/vibe/core/nuage/exceptions.py b/vibe/core/nuage/exceptions.py deleted file mode 100644 index fc84cf2..0000000 --- a/vibe/core/nuage/exceptions.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -from enum import StrEnum -from http import HTTPStatus - -import httpx - - -class ErrorCode(StrEnum): - TEMPORAL_CONNECTION_ERROR = "temporal_connection_error" - GET_EVENTS_STREAM_ERROR = "get_events_stream_error" - POST_EXECUTIONS_SIGNALS_ERROR = "post_executions_signals_error" - POST_EXECUTIONS_UPDATES_ERROR = "post_executions_updates_error" - GET_EXECUTIONS_ERROR = "get_executions_error" - - -class WorkflowsException(Exception): - def __init__( - self, - message: str, - status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR, - code: ErrorCode = ErrorCode.TEMPORAL_CONNECTION_ERROR, - ) -> None: - self.status = status - self.message = message - self.code = code - - def __str__(self) -> str: - return f"{self.message} (code={self.code}, status={self.status.value})" - - @classmethod - def from_api_client_error( - cls, - exc: Exception, - message: str = "HTTP request failed", - code: ErrorCode = ErrorCode.TEMPORAL_CONNECTION_ERROR, - ) -> WorkflowsException: - status = HTTPStatus.INTERNAL_SERVER_ERROR - if isinstance(exc, httpx.HTTPStatusError): - try: - status = HTTPStatus(exc.response.status_code) - except ValueError: - pass - if isinstance(exc, httpx.ConnectError | httpx.TimeoutException): - status = HTTPStatus.BAD_GATEWAY - return cls(message=f"{message}: {exc}", code=code, status=status) diff --git a/vibe/core/nuage/remote_events_source.py b/vibe/core/nuage/remote_events_source.py deleted file mode 100644 index 1fdddbd..0000000 --- a/vibe/core/nuage/remote_events_source.py +++ /dev/null @@ -1,207 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import AsyncGenerator -from typing import Any - -from pydantic import TypeAdapter, ValidationError - -from vibe.core.agent_loop import AgentLoopStateError -from vibe.core.config import VibeConfig -from vibe.core.logger import logger -from vibe.core.nuage.agent_models import ( - _SUBMIT_INPUT_UPDATE_NAME, - ChatInputModel, - SubmitInputModel, -) -from vibe.core.nuage.client import WorkflowsClient -from vibe.core.nuage.events import WorkflowEvent -from vibe.core.nuage.exceptions import ErrorCode, WorkflowsException -from vibe.core.nuage.remote_workflow_event_translator import ( - PendingInputRequest, - RemoteWorkflowEventTranslator, -) -from vibe.core.nuage.streaming import StreamEventsQueryParams -from vibe.core.nuage.workflow import WorkflowExecutionStatus -from vibe.core.tools.manager import ToolManager -from vibe.core.types import AgentStats, BaseEvent, LLMMessage, Role - -_RETRYABLE_STREAM_ERRORS = ("peer closed connection", "incomplete chunked read") -_WORKFLOW_EVENT_ADAPTER = TypeAdapter(WorkflowEvent) - - -class RemoteEventsSource: - def __init__(self, session_id: str, config: VibeConfig) -> None: - self.session_id = session_id - self._config = config - self.messages: list[LLMMessage] = [] - self.stats = AgentStats() - self._tool_manager = ToolManager(lambda: config) - self._next_start_seq = 0 - self._client: WorkflowsClient | None = None - self._translator = RemoteWorkflowEventTranslator( - available_tools=self._tool_manager._all_tools, - stats=self.stats, - merge_message=self._merge_message, - ) - - @property - def is_waiting_for_input(self) -> bool: - return self._translator.pending_input_request is not None - - @property - def _pending_input_request(self) -> PendingInputRequest | None: - return self._translator.pending_input_request - - @_pending_input_request.setter - def _pending_input_request(self, value: PendingInputRequest | None) -> None: - self._translator.pending_input_request = value - - @property - def _task_state(self) -> dict[str, dict[str, Any]]: - return self._translator.task_state - - @property - def is_terminated(self) -> bool: - return self._translator.last_status is not None - - @property - def is_failed(self) -> bool: - return self._translator.last_status == WorkflowExecutionStatus.FAILED - - @property - def is_canceled(self) -> bool: - return self._translator.last_status == WorkflowExecutionStatus.CANCELED - - @property - def client(self) -> WorkflowsClient: - if self._client is None: - self._client = WorkflowsClient( - base_url=self._config.vibe_code_base_url, - api_key=self._config.vibe_code_api_key, - timeout=self._config.api_timeout, - ) - return self._client - - async def close(self) -> None: - if self._client is not None: - await self._client.aclose() - self._client = None - - async def attach(self) -> AsyncGenerator[BaseEvent, None]: - async for event in self._stream_remote_events(stop_on_idle_boundary=False): - yield event - for event in self._translator.flush_open_tool_calls(): - yield event - - async def send_prompt(self, msg: str) -> None: - pending = self._translator.pending_input_request - if pending is None: - return - - if not self._is_chat_input_request(pending): - raise AgentLoopStateError( - "Remote workflow is waiting for structured input that this UI does not support." - ) - - await self.client.update_workflow( - self.session_id, - _SUBMIT_INPUT_UPDATE_NAME, - SubmitInputModel( - task_id=pending.task_id, - input={"message": [{"type": "text", "text": msg}]}, - ), - ) - self._translator.pending_input_request = None - - def _is_chat_input_request(self, request: PendingInputRequest) -> bool: - title = request.input_schema.get("title") - return title == ChatInputModel.model_config.get("title") - - async def _stream_remote_events( - self, stop_on_idle_boundary: bool = True - ) -> AsyncGenerator[BaseEvent]: - retry_count = 0 - max_retry_count = 3 - done = False - - while not done: - params = StreamEventsQueryParams( - workflow_exec_id=self.session_id, start_seq=self._next_start_seq - ) - stream = self.client.stream_events(params) - try: - async for payload in stream: - retry_count = 0 - if payload.broker_sequence is not None: - self._next_start_seq = payload.broker_sequence + 1 - - event = self._normalize_stream_event(payload.data) - if event is None: - continue - for emitted_event in self._consume_workflow_event(event): - yield emitted_event - - if self.is_terminated: - done = True - break - - if stop_on_idle_boundary and self._is_idle_boundary(event): - done = True - break - else: - break - - except WorkflowsException as exc: - if self._is_retryable_stream_disconnect(exc): - retry_count += 1 - if retry_count > max_retry_count: - break - await asyncio.sleep(0.2 * retry_count) - continue - raise AgentLoopStateError(str(exc)) from exc - finally: - await stream.aclose() - - def _normalize_stream_event( - self, event: WorkflowEvent | dict[str, Any] - ) -> WorkflowEvent | None: - if not isinstance(event, dict): - return event - try: - return _WORKFLOW_EVENT_ADAPTER.validate_python(event) - except ValidationError: - return None - - def _consume_workflow_event(self, event: WorkflowEvent) -> list[BaseEvent]: - try: - return self._translator.consume_workflow_event(event) - except ValidationError: - logger.warning("Failed to consume remote workflow event", exc_info=True) - return [] - - def _is_retryable_stream_disconnect(self, exc: WorkflowsException) -> bool: - if exc.code != ErrorCode.GET_EVENTS_STREAM_ERROR: - return False - - msg = str(exc).lower() - return any(needle in msg for needle in _RETRYABLE_STREAM_ERRORS) - - def _is_idle_boundary(self, event: WorkflowEvent) -> bool: - return self._translator.is_idle_boundary(event) - - def _merge_message(self, message: LLMMessage) -> None: - if not self.messages: - self.messages.append(message) - return - - last_message = self.messages[-1] - if ( - last_message.role == message.role - and last_message.message_id == message.message_id - and message.role == Role.assistant - ): - self.messages[-1] = last_message + message - return - - self.messages.append(message) diff --git a/vibe/core/nuage/remote_workflow_event_models.py b/vibe/core/nuage/remote_workflow_event_models.py deleted file mode 100644 index 8b4e5ff..0000000 --- a/vibe/core/nuage/remote_workflow_event_models.py +++ /dev/null @@ -1,137 +0,0 @@ -from __future__ import annotations - -from typing import Any, Literal - -from pydantic import BaseModel, ConfigDict - - -class BaseUIState(BaseModel): - toolCallId: str = "" - - -class FileOperation(BaseModel): - type: str = "" - uri: str = "" - content: str = "" - - -class FileUIState(BaseUIState): - type: Literal["file"] = "file" - operations: list[FileOperation] = [] - - -class CommandResult(BaseModel): - status: str = "" - output: str = "" - - -class CommandUIState(BaseUIState): - type: Literal["command"] = "command" - command: str = "" - result: CommandResult | None = None - - -class GenericToolResult(BaseModel): - status: str = "" - error: str | None = None - - -class GenericToolUIState(BaseUIState): - model_config = ConfigDict(extra="allow") - type: Literal["generic_tool"] = "generic_tool" - arguments: dict[str, Any] = {} - result: GenericToolResult | None = None - - -AnyToolUIState = FileUIState | CommandUIState | GenericToolUIState - - -def parse_tool_ui_state(raw: dict[str, Any]) -> AnyToolUIState | None: - ui_type = raw.get("type") - if ui_type == "file": - return FileUIState.model_validate(raw) - if ui_type == "command": - return CommandUIState.model_validate(raw) - if ui_type == "generic_tool": - return GenericToolUIState.model_validate(raw) - return None - - -class WorkingState(BaseModel): - title: str = "" - content: str = "" - type: str = "" - toolUIState: dict[str, Any] | None = None - - -class ContentChunk(BaseModel): - type: str = "" - text: str = "" - - -class AssistantMessageState(BaseModel): - contentChunks: list[ContentChunk] = [] - - -class AgentToolCallState(BaseModel): - model_config = ConfigDict(extra="allow") - name: str = "" - tool_call_id: str = "" - kwargs: dict[str, Any] = {} - output: Any = None - - -class MessageSchema(BaseModel): - model_config = ConfigDict(extra="allow") - examples: list[Any] = [] - - -class InputSchemaProperties(BaseModel): - model_config = ConfigDict(extra="allow") - message: MessageSchema | None = None - - -class InputSchema(BaseModel): - model_config = ConfigDict(extra="allow") - properties: InputSchemaProperties | None = None - - -class PredefinedAnswersState(BaseModel): - model_config = ConfigDict(extra="allow") - input_schema: InputSchema | None = None - - -class WaitForInputInput(BaseModel): - model_config = ConfigDict(extra="allow") - message: Any = None - - -class WaitForInputPayload(BaseModel): - model_config = ConfigDict(extra="allow") - input: WaitForInputInput | None = None - - -class AskUserQuestion(BaseModel): - question: str - - -class AskUserQuestionArgs(BaseModel): - model_config = ConfigDict(extra="allow") - questions: list[AskUserQuestion] = [] - - -class PendingInputRequest(BaseModel): - task_id: str - input_schema: dict[str, Any] - label: str | None = None - - -class RemoteToolArgs(BaseModel): - model_config = ConfigDict(extra="allow") - summary: str | None = None - content: str | None = None - - -class RemoteToolResult(BaseModel): - model_config = ConfigDict(extra="allow") - message: str | None = None diff --git a/vibe/core/nuage/remote_workflow_event_translator.py b/vibe/core/nuage/remote_workflow_event_translator.py deleted file mode 100644 index 6931fe3..0000000 --- a/vibe/core/nuage/remote_workflow_event_translator.py +++ /dev/null @@ -1,1313 +0,0 @@ -from __future__ import annotations - -from collections.abc import AsyncGenerator, Callable -import json -from typing import Any, cast - -from jsonpatch import JsonPatch, JsonPatchException # type: ignore[import-untyped] -from pydantic import BaseModel, ValidationError - -from vibe.core.logger import logger -from vibe.core.nuage.agent_models import AgentCompletionState -from vibe.core.nuage.events import ( - CustomTaskCanceled, - CustomTaskCompleted, - CustomTaskFailed, - CustomTaskInProgress, - CustomTaskStarted, - CustomTaskTimedOut, - JSONPatchAppend, - JSONPatchPayload, - JSONPatchReplace, - JSONPayload, - WorkflowEvent, - WorkflowExecutionCanceled, - WorkflowExecutionCompleted, - WorkflowExecutionFailed, -) -from vibe.core.nuage.remote_workflow_event_models import ( - AgentToolCallState, - AnyToolUIState, - AskUserQuestionArgs, - AssistantMessageState, - BaseUIState, - CommandUIState, - FileUIState, - GenericToolUIState, - PendingInputRequest, - PredefinedAnswersState, - RemoteToolArgs, - RemoteToolResult, - WaitForInputPayload, - WorkingState, - parse_tool_ui_state, -) -from vibe.core.nuage.workflow import WorkflowExecutionStatus -from vibe.core.tools.base import BaseTool, BaseToolConfig, BaseToolState, ToolError -from vibe.core.tools.ui import ToolUIData -from vibe.core.types import ( - AgentStats, - AssistantEvent, - BaseEvent, - FunctionCall, - LLMMessage, - ReasoningEvent, - Role, - ToolCall, - ToolCallEvent, - ToolResultEvent, - ToolStreamEvent, - UserMessageEvent, - WaitingForInputEvent, -) - -_WAIT_FOR_INPUT_TASK_TYPE = "wait_for_input" -_STEER_INPUT_LABEL = "Send a message to steer..." -# These names must match the remote workflow's tool naming convention -_ASK_USER_QUESTION_TOOL = "ask_user_question" -_SEND_USER_MESSAGE_TOOL = "send_user_message" - - -def _get_value_at_path(path: str, obj: Any) -> Any: - if not path or path == "/": - return obj - parts = path.split("/")[1:] - current = obj - for part in parts: - if current is None: - return None - if isinstance(current, dict) and part in current: - current = current[part] - elif isinstance(current, list): - try: - current = current[int(part)] - except (ValueError, IndexError): - return None - else: - return None - return current - - -def _set_value_at_path(path: str, obj: Any, value: Any) -> None: - if not path or path == "/": - return - parts = path.split("/")[1:] - current = obj - for part in parts[:-1]: - if isinstance(current, dict) and part in current: - current = current[part] - elif isinstance(current, list): - try: - current = current[int(part)] - except (ValueError, IndexError): - return - else: - return - last = parts[-1] - if isinstance(current, dict): - current[last] = value - elif isinstance(current, list): - try: - current[int(last)] = value - except (ValueError, IndexError): - pass - - -class _RemoteTool( - BaseTool[RemoteToolArgs, RemoteToolResult, BaseToolConfig, BaseToolState], - ToolUIData[RemoteToolArgs, RemoteToolResult], -): - remote_name = "remote_tool" - - @classmethod - def get_name(cls) -> str: - return cls.remote_name - - @classmethod - def get_status_text(cls) -> str: - return f"Running {cls.remote_name}" - - @classmethod - def format_call_display(cls, args: RemoteToolArgs) -> Any: - from vibe.core.tools.ui import ToolCallDisplay - - return ToolCallDisplay(summary=args.summary or cls.remote_name) - - @classmethod - def get_result_display(cls, event: ToolResultEvent) -> Any: - from vibe.core.tools.ui import ToolResultDisplay - - if event.error: - return ToolResultDisplay(success=False, message=event.error) - if isinstance(event.result, RemoteToolResult): - return ToolResultDisplay( - success=True, message=event.result.message or cls.remote_name - ) - return ToolResultDisplay(success=True, message=cls.remote_name) - - async def run( - self, args: RemoteToolArgs, ctx: Any = None - ) -> AsyncGenerator[ToolStreamEvent | RemoteToolResult, None]: - raise ToolError("Remote workflow tools cannot be invoked locally") - yield # type: ignore[misc] - - -_REMOTE_TOOL_CACHE: dict[str, type[_RemoteTool]] = {} - - -def _remote_tool_class(tool_name: str) -> type[_RemoteTool]: - cached = _REMOTE_TOOL_CACHE.get(tool_name) - if cached is not None: - return cached - - class_name = "".join( - char if char.isalnum() or char == "_" else "_" - for char in f"RemoteTool_{tool_name.replace('-', '_')}" - ) - tool_class = type( - class_name, (_RemoteTool,), {"remote_name": tool_name, "__module__": __name__} - ) - _REMOTE_TOOL_CACHE[tool_name] = tool_class - return tool_class - - -class RemoteWorkflowEventTranslator: - def __init__( - self, - *, - available_tools: dict[str, type[BaseTool]], - stats: AgentStats, - merge_message: Callable[[LLMMessage], None], - ) -> None: - self._available_tools = available_tools - self._stats = stats - self._merge_message = merge_message - self._task_state: dict[str, dict[str, Any]] = {} - self._completion_message_ids: dict[str, str] = {} - self._seen_tool_call_ids: set[str] = set() - self._seen_tool_results: set[str] = set() - self._open_tool_calls: dict[str, str] = {} - self._input_snapshots: dict[str, str] = {} - self._tool_stream_snapshots: dict[str, str] = {} - self._pending_tool_progress: dict[str, tuple[str, str]] = {} - self._pending_input_request: PendingInputRequest | None = None - self._pending_question_prompt: str | None = None - self._pending_ask_user_question_call_id: str | None = None - self._steer_task_ids: set[str] = set() - self._invalid_steer_task_ids: set[str] = set() - self._last_status: WorkflowExecutionStatus | None = None - - @property - def pending_input_request(self) -> PendingInputRequest | None: - return self._pending_input_request - - @pending_input_request.setter - def pending_input_request(self, value: PendingInputRequest | None) -> None: - self._pending_input_request = value - - @property - def last_status(self) -> WorkflowExecutionStatus | None: - return self._last_status - - @property - def task_state(self) -> dict[str, dict[str, Any]]: - return self._task_state - - def consume_workflow_event(self, event: WorkflowEvent) -> list[BaseEvent]: - if self._consume_workflow_lifecycle_event(event): - return [] - - wait_for_input_events = self._consume_wait_for_input_event(event) - if wait_for_input_events is not None: - return wait_for_input_events - - if not isinstance( - event, - ( - CustomTaskStarted, - CustomTaskInProgress, - CustomTaskCompleted, - CustomTaskFailed, - CustomTaskTimedOut, - CustomTaskCanceled, - ), - ): - return [] - - return self._consume_agent_task_event(event) - - def is_idle_boundary(self, event: WorkflowEvent) -> bool: - if isinstance( - event, - ( - WorkflowExecutionCompleted, - WorkflowExecutionFailed, - WorkflowExecutionCanceled, - ), - ): - return True - - if isinstance(event, CustomTaskStarted): - return event.attributes.custom_task_type == _WAIT_FOR_INPUT_TASK_TYPE - - if not isinstance(event, (CustomTaskInProgress, CustomTaskCompleted)): - return False - - if event.attributes.custom_task_type != "AgentInputState": - return False - - if self._open_tool_calls: - return False - - state = self._task_state.get(event.attributes.custom_task_id, {}) - return state.get("input") is None - - def flush_open_tool_calls(self) -> list[BaseEvent]: - events: list[BaseEvent] = [] - for tool_call_id, tool_name in list(self._open_tool_calls.items()): - tool_class = self._resolve_tool_class(tool_name) - events.append( - ToolResultEvent( - tool_name=tool_name, - tool_class=tool_class, - tool_call_id=tool_call_id, - ) - ) - self._open_tool_calls.clear() - return events - - def _consume_workflow_lifecycle_event(self, event: WorkflowEvent) -> bool: - if isinstance(event, WorkflowExecutionCompleted): - self._last_status = WorkflowExecutionStatus.COMPLETED - self._pending_input_request = None - return True - - if isinstance(event, WorkflowExecutionCanceled): - self._last_status = WorkflowExecutionStatus.CANCELED - self._pending_input_request = None - return True - - if isinstance(event, WorkflowExecutionFailed): - self._last_status = WorkflowExecutionStatus.FAILED - self._pending_input_request = None - return True - - return False - - def _consume_wait_for_input_event( - self, event: WorkflowEvent - ) -> list[BaseEvent] | None: - if isinstance(event, CustomTaskStarted): - return self._wait_for_input_started_events(event) - - if not isinstance( - event, - ( - CustomTaskCompleted, - CustomTaskCanceled, - CustomTaskFailed, - CustomTaskTimedOut, - ), - ): - return None - return self._wait_for_input_terminal_events(event) - - def _consume_agent_task_event( - self, - event: ( - CustomTaskStarted - | CustomTaskInProgress - | CustomTaskCompleted - | CustomTaskFailed - | CustomTaskTimedOut - | CustomTaskCanceled - ), - ) -> list[BaseEvent]: - task_type = event.attributes.custom_task_type - if task_type not in { - "AgentCompletionState", - "AgentToolCallState", - "AgentStepState", - "AgentInputState", - "assistant_message", - "working", - }: - return [] - - if isinstance( - event, (CustomTaskFailed, CustomTaskTimedOut, CustomTaskCanceled) - ): - return self._agent_task_terminal_events(event) - - previous_state, state = self._get_current_state(event) - task_id = event.attributes.custom_task_id - events: list[BaseEvent] = [] - - match task_type: - case "AgentCompletionState": - events = self._completion_events(task_id, previous_state, state) - case "assistant_message": - events = self._assistant_message_events(task_id, previous_state, state) - case "working": - events = self._working_events(task_id, previous_state, state, event) - case "AgentToolCallState": - events = self._tool_events(task_id, state, event) - case "AgentInputState": - self._input_events(task_id, state) - return events - - def _wait_for_input_started_events( - self, event: CustomTaskStarted - ) -> list[BaseEvent] | None: - if event.attributes.custom_task_type != _WAIT_FOR_INPUT_TASK_TYPE: - return None - - payload_value = event.attributes.payload.value - label = self._wait_for_input_label(payload_value) - - if label == _STEER_INPUT_LABEL: - return self._steer_wait_for_input_started(event, payload_value) - - if isinstance(payload_value, dict): - self._set_pending_input_request( - event.attributes.custom_task_id, payload_value - ) - - events: list[BaseEvent] = [] - if label: - events.extend(self._assistant_question_events(label)) - - events.append( - WaitingForInputEvent( - task_id=event.attributes.custom_task_id, - label=label, - predefined_answers=self._extract_predefined_answers(payload_value), - ) - ) - return events - - def _steer_wait_for_input_started( - self, event: CustomTaskStarted, payload_value: Any - ) -> list[BaseEvent]: - task_id = event.attributes.custom_task_id - self._steer_task_ids.add(task_id) - if self._pending_input_request is None and isinstance(payload_value, dict): - try: - self._set_pending_input_request(task_id, payload_value) - except ValidationError: - self._invalid_steer_task_ids.add(task_id) - raise - return [] - - def _steer_wait_for_input_terminal( - self, - event: CustomTaskCompleted - | CustomTaskCanceled - | CustomTaskFailed - | CustomTaskTimedOut, - payload_value: Any, - ) -> list[BaseEvent]: - task_id = event.attributes.custom_task_id - self._steer_task_ids.discard(task_id) - invalid_steer_task = task_id in self._invalid_steer_task_ids - self._invalid_steer_task_ids.discard(task_id) - if ( - self._pending_input_request is not None - and self._pending_input_request.task_id == task_id - ): - self._pending_input_request = None - if isinstance(event, CustomTaskCompleted) and not invalid_steer_task: - return self._completed_wait_for_input_events(payload_value) - return [] - - def _set_pending_input_request( - self, task_id: str, payload_value: dict[str, Any] - ) -> None: - self._pending_input_request = PendingInputRequest.model_validate({ - "task_id": task_id, - **payload_value, - }) - - def _wait_for_input_label(self, payload_value: Any) -> str | None: - if not isinstance(payload_value, dict): - return None - label = payload_value.get("label") - return label if isinstance(label, str) else None - - def _is_steer_wait_for_input(self, task_id: str, payload_value: Any) -> bool: - if task_id in self._steer_task_ids: - return True - return self._wait_for_input_label(payload_value) == _STEER_INPUT_LABEL - - def _wait_for_input_terminal_events( - self, - event: CustomTaskCompleted - | CustomTaskCanceled - | CustomTaskFailed - | CustomTaskTimedOut, - ) -> list[BaseEvent] | None: - if event.attributes.custom_task_type != _WAIT_FOR_INPUT_TASK_TYPE: - return None - - payload_value = ( - event.attributes.payload.value - if isinstance(event, CustomTaskCompleted) - else None - ) - if self._is_steer_wait_for_input( - event.attributes.custom_task_id, payload_value - ): - return self._steer_wait_for_input_terminal(event, payload_value) - - self._pending_input_request = None - self._pending_question_prompt = None - ask_user_question_call_id = self._pending_ask_user_question_call_id - self._pending_ask_user_question_call_id = None - - if not isinstance(event, CustomTaskCompleted): - if ask_user_question_call_id: - return self._emit_tool_result_events( - tool_name=_ASK_USER_QUESTION_TOOL, - tool_call_id=ask_user_question_call_id, - output=None, - error="Cancelled", - ) - return [] - events = self._completed_wait_for_input_events( - event.attributes.payload.value, ask_user_question_call_id - ) - return events - - def _completed_wait_for_input_events( - self, payload_value: Any, ask_user_question_call_id: str | None = None - ) -> list[BaseEvent]: - if not isinstance(payload_value, dict): - return [] - payload = WaitForInputPayload.model_validate(payload_value) - if payload.input is None: - return [] - - textual_input = self._extract_user_text(payload.input.message) - if not textual_input: - return [] - - events: list[BaseEvent] = [] - if ask_user_question_call_id: - events.extend( - self._emit_tool_result_events( - tool_name=_ASK_USER_QUESTION_TOOL, - tool_call_id=ask_user_question_call_id, - output={"answer": textual_input}, - error=None, - ) - ) - - user_message = LLMMessage(role=Role.user, content=textual_input) - self._merge_message(user_message) - if user_message.message_id is None: - return events - - events.append( - UserMessageEvent(content=textual_input, message_id=user_message.message_id) - ) - return events - - def _get_current_state( - self, event: CustomTaskStarted | CustomTaskInProgress | CustomTaskCompleted - ) -> tuple[dict[str, Any], dict[str, Any]]: - task_id = event.attributes.custom_task_id - previous_state = self._task_state.get(task_id, {}) - if isinstance(event.attributes.payload, JSONPayload): - new_state = self._normalize_state(event.attributes.payload.value) - else: - new_state = self._apply_json_patch( - previous_state, cast(JSONPatchPayload, event.attributes.payload) - ) - self._task_state[task_id] = new_state - return previous_state, new_state - - def _agent_task_terminal_events( - self, event: CustomTaskFailed | CustomTaskTimedOut | CustomTaskCanceled - ) -> list[BaseEvent]: - if event.attributes.custom_task_type != "AgentToolCallState": - return [] - - task_id = event.attributes.custom_task_id - state = self._task_state.get(task_id, {}) - return self._tool_terminal_events(task_id, state, event) - - def _normalize_state(self, value: Any) -> dict[str, Any]: - if isinstance(value, dict): - return dict(value) - return {} - - def _apply_json_patch( - self, previous_state: dict[str, Any], payload: JSONPatchPayload - ) -> dict[str, Any]: - new_state = cast(dict[str, Any], self._json_safe_value(previous_state)) - - for patch in payload.value: - if isinstance(patch, JSONPatchAppend): - current = _get_value_at_path(patch.path, new_state) - _set_value_at_path( - patch.path, new_state, f"{current or ''}{patch.value}" - ) - elif isinstance(patch, JSONPatchReplace) and not patch.path.strip("/"): - new_state = self._normalize_state(patch.value) - else: - try: - new_state = JsonPatch([ - {"op": patch.op, "path": patch.path, "value": patch.value} - ]).apply(new_state) - except JsonPatchException: - pass - - return new_state - - def _completion_events( - self, task_id: str, previous_state: dict[str, Any], state: dict[str, Any] - ) -> list[BaseEvent]: - completion_state = AgentCompletionState.model_validate(state) - previous_completion_state = AgentCompletionState.model_validate(previous_state) - current_content = completion_state.content - current_reasoning = completion_state.reasoning_content - previous_content = previous_completion_state.content - previous_reasoning = previous_completion_state.reasoning_content - - if not ( - (not current_content or current_content.startswith(previous_content)) - and ( - not current_reasoning - or current_reasoning.startswith(previous_reasoning) - ) - ): - previous_content = "" - previous_reasoning = "" - self._completion_message_ids.pop(task_id, None) - - content_delta = current_content[len(previous_content) :] - reasoning_delta = current_reasoning[len(previous_reasoning) :] - if not content_delta and not reasoning_delta: - return [] - - message_id = self._completion_message_ids.setdefault( - task_id, LLMMessage(role=Role.assistant).message_id or task_id - ) - - self._merge_message( - LLMMessage( - role=Role.assistant, - content=content_delta or None, - reasoning_content=reasoning_delta or None, - message_id=message_id, - ) - ) - - events: list[BaseEvent] = [] - if reasoning_delta: - events.append( - ReasoningEvent(content=reasoning_delta, message_id=message_id) - ) - if content_delta: - events.append(AssistantEvent(content=content_delta, message_id=message_id)) - return events - - def _assistant_message_events( - self, task_id: str, previous_state: dict[str, Any], state: dict[str, Any] - ) -> list[BaseEvent]: - current_text = self._extract_content_chunks_text(state) - previous_text = self._extract_content_chunks_text(previous_state) - - if not (not current_text or current_text.startswith(previous_text)): - previous_text = "" - self._completion_message_ids.pop(task_id, None) - - delta = current_text[len(previous_text) :] - if not delta: - return [] - - message_id = self._completion_message_ids.setdefault( - task_id, LLMMessage(role=Role.assistant).message_id or task_id - ) - self._merge_message( - LLMMessage(role=Role.assistant, content=delta, message_id=message_id) - ) - return [AssistantEvent(content=delta, message_id=message_id)] - - def _extract_content_chunks_text(self, state: dict[str, Any]) -> str: - msg = AssistantMessageState.model_validate(state) - return "".join( - chunk.text for chunk in msg.contentChunks if chunk.type == "text" - ) - - def _working_events( - self, - task_id: str, - previous_state: dict[str, Any], - state: dict[str, Any], - event: CustomTaskStarted | CustomTaskInProgress | CustomTaskCompleted, - ) -> list[BaseEvent]: - working = WorkingState.model_validate(state) - previous_working = WorkingState.model_validate(previous_state) - parsed_ui_state = ( - parse_tool_ui_state(working.toolUIState) if working.toolUIState else None - ) - base_ui_state = ( - BaseUIState.model_validate(working.toolUIState) - if working.toolUIState - else None - ) - tool_call_id = base_ui_state.toolCallId if base_ui_state else None - - if not tool_call_id: - return self._working_events_without_tool_call( - task_id, working, previous_working, parsed_ui_state, event - ) - - return self._working_events_with_tool_call( - task_id, working, parsed_ui_state, tool_call_id, event - ) - - def _working_events_without_tool_call( - self, - task_id: str, - working: WorkingState, - previous_working: WorkingState, - parsed_ui_state: AnyToolUIState | None, - event: CustomTaskStarted | CustomTaskInProgress | CustomTaskCompleted, - ) -> list[BaseEvent]: - if isinstance(event, CustomTaskStarted): - return [] - - if working.type == "thinking": - return self._working_thinking_events(task_id, working, previous_working) - - tool_name = working.title.removeprefix("Executing ") - if not tool_name or tool_name == _SEND_USER_MESSAGE_TOOL: - return [] - - events = self._emit_tool_call_events( - tool_name=tool_name, - tool_call_id=task_id, - tool_args={"summary": working.title}, - task_key=task_id, - ) - stream_output = self._working_stream_output( - parsed_ui_state=parsed_ui_state, content=working.content - ) - if stream_output: - events.extend( - self._tool_stream_events( - tool_name=tool_name, - tool_call_id=task_id, - result_key=task_id, - output=stream_output, - ) - ) - if isinstance(event, CustomTaskCompleted): - output, error = self._tool_result_from_ui_state(parsed_ui_state) - events.extend( - self._emit_tool_result_events( - tool_name=tool_name, - tool_call_id=task_id, - output=output or {"message": working.title}, - error=error, - ) - ) - return events - - def _working_thinking_events( - self, task_id: str, working: WorkingState, previous_working: WorkingState - ) -> list[BaseEvent]: - delta = working.content[len(previous_working.content) :] - if not delta: - return [] - message_id = self._completion_message_ids.setdefault( - task_id, LLMMessage(role=Role.assistant).message_id or task_id - ) - self._merge_message( - LLMMessage( - role=Role.assistant, reasoning_content=delta, message_id=message_id - ) - ) - return [ReasoningEvent(content=delta, message_id=message_id)] - - def _working_events_with_tool_call( - self, - task_id: str, - working: WorkingState, - parsed_ui_state: AnyToolUIState | None, - tool_call_id: str, - event: CustomTaskStarted | CustomTaskInProgress | CustomTaskCompleted, - ) -> list[BaseEvent]: - tool_name = working.title.removeprefix("Executing ") - if not tool_name or tool_name == _SEND_USER_MESSAGE_TOOL: - return [] - - tool_args = self._tool_args_from_ui_state(parsed_ui_state) - events = self._emit_tool_call_events( - tool_name=tool_name, - tool_call_id=tool_call_id, - tool_args=tool_args, - task_key=task_id, - ) - - if not isinstance(parsed_ui_state, FileUIState) and working.content: - events.extend( - self._tool_stream_events( - tool_name=tool_name, - tool_call_id=tool_call_id, - result_key=tool_call_id, - output=working.content, - ) - ) - - if isinstance(event, CustomTaskCompleted): - output, error = self._tool_result_from_ui_state(parsed_ui_state) - events.extend( - self._emit_tool_result_events( - tool_name=tool_name, - tool_call_id=tool_call_id, - output=output or {"message": working.title}, - error=error, - ) - ) - - return events - - def _working_stream_output( - self, *, parsed_ui_state: AnyToolUIState | None, content: str - ) -> Any: - if content: - return content - - output, error = self._tool_result_from_ui_state(parsed_ui_state) - if error: - return {"error": error} - if output is not None: - return output - return None - - def _tool_events( - self, - task_id: str, - state: dict[str, Any], - event: CustomTaskStarted | CustomTaskInProgress | CustomTaskCompleted, - ) -> list[BaseEvent]: - parsed = AgentToolCallState.model_validate(state) - tool_name = parsed.name - if tool_name == _SEND_USER_MESSAGE_TOOL: - return [] - - events = self._tool_call_and_stream_events(task_id, state) - - if not isinstance(event, CustomTaskCompleted) or not tool_name: - return events - - tool_call_id = parsed.tool_call_id or task_id - events.extend( - self._emit_tool_result_events( - tool_name=tool_name, - tool_call_id=tool_call_id, - output=parsed.output, - error=None, - ) - ) - return events - - def _tool_args_from_ui_state( - self, ui_state: AnyToolUIState | None - ) -> dict[str, Any]: - if isinstance(ui_state, FileUIState): - if not ui_state.operations: - return {} - op = ui_state.operations[0] - return {"path": op.uri, "content": op.content} - if isinstance(ui_state, CommandUIState): - return {"command": ui_state.command} - if isinstance(ui_state, GenericToolUIState): - return ui_state.arguments - return {} - - def _tool_result_from_ui_state( - self, ui_state: AnyToolUIState | None - ) -> tuple[dict[str, Any] | None, str | None]: - if isinstance(ui_state, FileUIState): - return self._file_ui_result(ui_state) - if isinstance(ui_state, CommandUIState): - return self._command_ui_result(ui_state) - if isinstance(ui_state, GenericToolUIState): - return self._generic_ui_result(ui_state) - return None, None - - def _file_ui_result( - self, ui_state: FileUIState - ) -> tuple[dict[str, Any] | None, str | None]: - if not ui_state.operations: - return None, "No file operations in result" - op = ui_state.operations[0] - return { - "path": op.uri, - "bytes_written": len(op.content.encode()), - "content": op.content, - }, None - - def _command_ui_result( - self, ui_state: CommandUIState - ) -> tuple[dict[str, Any] | None, str | None]: - result = ui_state.result - if result is None or result.status == "running": - return None, None - if result.status == "failed": - return None, result.output or "Command failed" - return { - "command": ui_state.command, - "stdout": result.output, - "stderr": "", - "returncode": 0, - }, None - - def _generic_ui_result( - self, ui_state: GenericToolUIState - ) -> tuple[dict[str, Any] | None, str | None]: - result = ui_state.result - if result is None or result.status == "running": - return None, None - if result.status == "failed": - return None, result.error or "Tool failed" - return ui_state.arguments, None - - def _emit_tool_call_events( - self, - *, - tool_name: str, - tool_call_id: str, - tool_args: dict[str, Any], - task_key: str, - ) -> list[BaseEvent]: - if not tool_name or tool_name == _SEND_USER_MESSAGE_TOOL: - return [] - - question_events = self._ask_user_question_events(tool_name, tool_args) - tool_class = self._resolve_tool_class(tool_name) - args_model, _ = tool_class._get_tool_args_results() - validated_args: BaseModel | None = None - try: - validated_args = args_model.model_validate(tool_args) - except ValidationError: - validated_args = None - - if tool_call_id in self._seen_tool_call_ids: - return [] - - self._seen_tool_call_ids.add(tool_call_id) - self._stats.tool_calls_agreed += 1 - self._open_tool_calls[tool_call_id] = tool_name - if tool_name == _ASK_USER_QUESTION_TOOL: - self._pending_ask_user_question_call_id = tool_call_id - self._merge_message( - LLMMessage( - role=Role.assistant, - tool_calls=[ - ToolCall( - id=tool_call_id, - function=FunctionCall( - name=tool_name, arguments=self._json_string(tool_args) - ), - ) - ], - ) - ) - return [ - ToolCallEvent( - tool_call_id=tool_call_id, - tool_name=tool_name, - tool_class=tool_class, - args=validated_args, - ), - *question_events, - ] - - def _tool_stream_events( - self, tool_name: str, tool_call_id: str, result_key: str, output: Any - ) -> list[ToolStreamEvent]: - preview = self._output_preview_text(output) - if not preview: - return [] - - previous_preview = self._tool_stream_snapshots.get(result_key, "") - if preview == previous_preview: - return [] - - self._tool_stream_snapshots[result_key] = preview - if not preview.strip(): - return [] - - return [ - ToolStreamEvent( - tool_name=tool_name, tool_call_id=tool_call_id, message=preview - ) - ] - - def _resolve_tool_class(self, tool_name: str) -> type[BaseTool]: - if tool_class := self._available_tools.get(tool_name): - return tool_class - - short_name = tool_name.rsplit(".", 1)[-1] - if short_name != tool_name and ( - tool_class := self._available_tools.get(short_name) - ): - return tool_class - - suffix_matches = [ - available_tool_class - for available_name, available_tool_class in self._available_tools.items() - if available_name.endswith(f".{short_name}") or available_name == short_name - ] - if len(suffix_matches) == 1: - return suffix_matches[0] - - return _remote_tool_class(tool_name) - - def _finalize_tool_call(self, tool_call_id: str) -> None: - self._seen_tool_results.add(tool_call_id) - self._open_tool_calls.pop(tool_call_id, None) - self._pending_tool_progress.pop(tool_call_id, None) - self._tool_stream_snapshots.pop(tool_call_id, None) - - def _emit_tool_result_events( - self, *, tool_name: str, tool_call_id: str, output: Any, error: str | None - ) -> list[BaseEvent]: - if tool_name == _SEND_USER_MESSAGE_TOOL: - return [] - - if tool_call_id in self._seen_tool_results: - return [] - - tool_class = self._resolve_tool_class(tool_name) - - if error: - self._finalize_tool_call(tool_call_id) - return self._emit_tool_error_result( - tool_name=tool_name, - tool_class=tool_class, - tool_call_id=tool_call_id, - error=error, - ) - - if output is None: - self._finalize_tool_call(tool_call_id) - return self._emit_missing_tool_output( - tool_name=tool_name, tool_class=tool_class, tool_call_id=tool_call_id - ) - - output_dict = self._normalize_output(output) - output_error = output_dict.get("error") - if isinstance(output_error, str) and output_error: - self._finalize_tool_call(tool_call_id) - return self._emit_tool_error_result( - tool_name=tool_name, - tool_class=tool_class, - tool_call_id=tool_call_id, - error=output_error, - ) - - self._finalize_tool_call(tool_call_id) - - _, result_model = tool_class._get_tool_args_results() - result_value: BaseModel | None = None - try: - result_value = result_model.model_validate(output_dict) - except ValidationError: - result_value = None - - self._stats.tool_calls_succeeded += 1 - result_text = "\n".join(f"{k}: {v}" for k, v in output_dict.items()) - self._merge_message( - LLMMessage( - role=Role.tool, - name=tool_name, - tool_call_id=tool_call_id, - content=result_text, - ) - ) - return [ - ToolResultEvent( - tool_name=tool_name, - tool_class=tool_class, - result=result_value, - tool_call_id=tool_call_id, - ) - ] - - def _emit_missing_tool_output( - self, tool_name: str, tool_class: type[BaseTool], tool_call_id: str - ) -> list[BaseEvent]: - error = "Tool did not produce output" - self._stats.tool_calls_failed += 1 - self._merge_message( - LLMMessage( - role=Role.tool, name=tool_name, tool_call_id=tool_call_id, content=error - ) - ) - return [ - ToolResultEvent( - tool_name=tool_name, - tool_class=tool_class, - error=error, - tool_call_id=tool_call_id, - ) - ] - - def _emit_tool_error_result( - self, tool_name: str, tool_class: type[BaseTool], tool_call_id: str, error: str - ) -> list[BaseEvent]: - self._stats.tool_calls_failed += 1 - self._merge_message( - LLMMessage( - role=Role.tool, name=tool_name, tool_call_id=tool_call_id, content=error - ) - ) - return [ - ToolResultEvent( - tool_name=tool_name, - tool_class=tool_class, - error=error, - tool_call_id=tool_call_id, - ) - ] - - def _tool_call_and_stream_events( - self, task_id: str, state: dict[str, Any] - ) -> list[BaseEvent]: - parsed = AgentToolCallState.model_validate(state) - tool_name = parsed.name - tool_call_id = parsed.tool_call_id or task_id - tool_args = self._normalize_mapping(parsed.kwargs) - events = self._emit_tool_call_events( - tool_name=tool_name, - tool_call_id=tool_call_id, - tool_args=tool_args, - task_key=task_id, - ) - - if pending_progress := self._pending_tool_progress.pop(tool_call_id, None): - pending_tool_name, pending_content = pending_progress - if pending_tool_name == tool_name: - events.extend( - self._tool_stream_events( - tool_name=tool_name, - tool_call_id=tool_call_id, - result_key=tool_call_id, - output=pending_content, - ) - ) - - if tool_call_id in self._seen_tool_results: - return events - - events.extend( - self._tool_stream_events( - tool_name=tool_name, - tool_call_id=tool_call_id, - result_key=tool_call_id, - output=parsed.output, - ) - ) - return events - - def _normalize_mapping(self, value: Any) -> dict[str, Any]: - if isinstance(value, dict): - return cast(dict[str, Any], self._json_safe_value(value)) - if isinstance(value, str): - try: - parsed = json.loads(value) - except json.JSONDecodeError: - return {} - if isinstance(parsed, dict): - return cast(dict[str, Any], self._json_safe_value(parsed)) - return {} - - def _normalize_output(self, output: Any) -> dict[str, Any]: - if isinstance(output, dict): - return cast(dict[str, Any], self._json_safe_value(output)) - if isinstance(output, str): - try: - parsed = json.loads(output) - except json.JSONDecodeError: - return {"value": output} - if isinstance(parsed, dict): - return cast(dict[str, Any], self._json_safe_value(parsed)) - return {"value": parsed} - return {"value": self._json_safe_value(output)} - - def _output_preview_text(self, output: Any) -> str | None: - output_dict = self._normalize_output(output) - - # Priority: known preview keys > raw string > single-value dict > all scalar fields - for key in ("preview", "message", "status_text", "status", "delta"): - value = output_dict.get(key) - if isinstance(value, str) and value: - return value - - if isinstance(output, str) and output: - return output - - if len(output_dict) == 1 and "value" in output_dict: - value = output_dict["value"] - return value if isinstance(value, str) and value else None - - return ( - "\n".join( - f"{key}: {value}" - for key, value in output_dict.items() - if value is not None and not isinstance(value, (dict, list)) - ) - or None - ) - - def _tool_terminal_events( - self, - task_id: str, - state: dict[str, Any], - event: CustomTaskFailed | CustomTaskTimedOut | CustomTaskCanceled, - ) -> list[BaseEvent]: - parsed = AgentToolCallState.model_validate(state) - tool_name = parsed.name - if not tool_name: - return [] - if tool_name == _SEND_USER_MESSAGE_TOOL: - return [] - if tool_name == _ASK_USER_QUESTION_TOOL: - self._pending_question_prompt = None - - tool_call_id = parsed.tool_call_id or task_id - if tool_call_id in self._seen_tool_results: - return [] - - tool_class = self._resolve_tool_class(tool_name) - error = self._tool_terminal_error(event) - self._finalize_tool_call(tool_call_id) - self._stats.tool_calls_failed += 1 - self._merge_message( - LLMMessage( - role=Role.tool, name=tool_name, tool_call_id=tool_call_id, content=error - ) - ) - return [ - ToolResultEvent( - tool_name=tool_name, - tool_class=tool_class, - error=error, - cancelled=isinstance(event, CustomTaskCanceled), - tool_call_id=tool_call_id, - ) - ] - - def _tool_terminal_error( - self, event: CustomTaskFailed | CustomTaskTimedOut | CustomTaskCanceled - ) -> str: - if isinstance(event, CustomTaskFailed): - return event.attributes.failure.message - - if isinstance(event, CustomTaskTimedOut): - timeout_type = event.attributes.timeout_type - return f"Timed out ({timeout_type})" if timeout_type else "Timed out" - - if event.attributes.reason: - return f"Canceled: {event.attributes.reason}" - return "Canceled" - - def _input_events(self, task_id: str, state: dict[str, Any]) -> None: - parsed = WaitForInputPayload.model_validate(state) - if parsed.input is None: - return - - textual_input = self._extract_user_text(parsed.input.message) - if not textual_input: - return - - if self._input_snapshots.get(task_id) == textual_input: - return - - self._input_snapshots[task_id] = textual_input - self._pending_question_prompt = None - self._merge_message(LLMMessage(role=Role.user, content=textual_input)) - - def _extract_user_text(self, value: Any) -> str | None: - if isinstance(value, str): - return value - if not isinstance(value, list): - return None - - parts: list[str] = [] - for item in value: - if isinstance(item, dict) and isinstance(item.get("text"), str): - parts.append(item["text"]) - - if not parts: - return None - return "".join(parts) - - def _extract_predefined_answers(self, value: Any) -> list[str] | None: - if not isinstance(value, dict): - return None - parsed = PredefinedAnswersState.model_validate(value) - if parsed.input_schema is None or parsed.input_schema.properties is None: - return None - message = parsed.input_schema.properties.message - if message is None: - return None - - answers: list[str] = [] - for example in message.examples: - answer = self._extract_user_text(example) - if not answer or answer.lower() == "other" or answer in answers: - continue - answers.append(answer) - - return answers or None - - def _ask_user_question_events( - self, tool_name: str, tool_args: dict[str, Any] - ) -> list[BaseEvent]: - if tool_name != _ASK_USER_QUESTION_TOOL: - return [] - - try: - parsed = AskUserQuestionArgs.model_validate(tool_args) - except ValidationError: - logger.warning("Failed to parse ask_user_question args", exc_info=True) - return [] - prompt = "\n\n".join(q.question for q in parsed.questions) - if not prompt: - return [] - - return self._assistant_question_events(prompt) - - def _assistant_question_events(self, prompt: str) -> list[BaseEvent]: - if self._pending_question_prompt == prompt: - return [] - - self._pending_question_prompt = prompt - message_id = LLMMessage(role=Role.assistant).message_id - self._merge_message( - LLMMessage(role=Role.assistant, content=prompt, message_id=message_id) - ) - return [AssistantEvent(content=prompt, message_id=message_id)] - - def _json_safe_value(self, value: Any) -> Any: - if isinstance(value, BaseModel): - return self._json_safe_value(value.model_dump(mode="json")) - if isinstance(value, dict): - return { - str(key): self._json_safe_value(item) for key, item in value.items() - } - if isinstance(value, list | tuple): - return [self._json_safe_value(item) for item in value] - if isinstance(value, set): - return [self._json_safe_value(item) for item in sorted(value, key=repr)] - return value - - def _json_string(self, value: Any) -> str: - return json.dumps(self._json_safe_value(value)) diff --git a/vibe/core/nuage/streaming.py b/vibe/core/nuage/streaming.py deleted file mode 100644 index 6becbd8..0000000 --- a/vibe/core/nuage/streaming.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -import time -from typing import Any - -from pydantic import BaseModel, Field - -from vibe.core.nuage.events import WorkflowEvent - - -class StreamEventWorkflowContext(BaseModel): - namespace: str = "" - workflow_name: str = "" - workflow_exec_id: str = "" - parent_workflow_exec_id: str | None = None - root_workflow_exec_id: str | None = None - - -class StreamEvent(BaseModel): - stream: str = "" - timestamp_unix_nano: int = Field(default_factory=lambda: time.time_ns()) - data: WorkflowEvent | dict[str, Any] - workflow_context: StreamEventWorkflowContext = Field( - default_factory=StreamEventWorkflowContext - ) - metadata: dict[str, Any] = Field(default_factory=dict) - broker_sequence: int | None = None - - -class StreamEventsQueryParams(BaseModel): - workflow_exec_id: str = "" - start_seq: int = 0 diff --git a/vibe/core/nuage/workflow.py b/vibe/core/nuage/workflow.py deleted file mode 100644 index d90f0d8..0000000 --- a/vibe/core/nuage/workflow.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from enum import StrEnum -from typing import Any - -from pydantic import BaseModel, Field - - -class WorkflowExecutionStatus(StrEnum): - RUNNING = "RUNNING" - COMPLETED = "COMPLETED" - FAILED = "FAILED" - CANCELED = "CANCELED" - TERMINATED = "TERMINATED" - CONTINUED_AS_NEW = "CONTINUED_AS_NEW" - TIMED_OUT = "TIMED_OUT" - - -class WorkflowExecutionWithoutResultResponse(BaseModel): - workflow_name: str - execution_id: str - parent_execution_id: str | None = None - root_execution_id: str = "" - status: WorkflowExecutionStatus | None = None - start_time: datetime - end_time: datetime | None = None - total_duration_ms: int | None = None - - -class WorkflowExecutionListResponse(BaseModel): - executions: list[WorkflowExecutionWithoutResultResponse] = Field( - default_factory=list - ) - next_page_token: str | None = None - - -class SignalWorkflowResponse(BaseModel): - message: str = "Signal accepted" - - -class UpdateWorkflowResponse(BaseModel): - update_name: str = "" - result: Any = None diff --git a/vibe/core/session/image_snapshot.py b/vibe/core/session/image_snapshot.py index 31adac4..b6ec3bc 100644 --- a/vibe/core/session/image_snapshot.py +++ b/vibe/core/session/image_snapshot.py @@ -1,10 +1,17 @@ from __future__ import annotations +import base64 import hashlib import mimetypes from pathlib import Path -from vibe.core.types import IMAGE_EXTENSIONS, ImageAttachment +from vibe.core.types import ( + IMAGE_EXTENSIONS, + MAX_IMAGE_BYTES, + FileImageSource, + ImageAttachment, + InlineImageSource, +) _DEFAULT_MIME_BY_EXT = { ".png": "image/png", @@ -14,11 +21,38 @@ _DEFAULT_MIME_BY_EXT = { ".webp": "image/webp", } +_EXT_BY_MIME = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", +} + class ImageSnapshotError(Exception): pass +def extension_for_mime(mime_type: str) -> str | None: + return _EXT_BY_MIME.get(mime_type) + + +def _check_size(data: bytes) -> None: + if len(data) > MAX_IMAGE_BYTES: + raise ImageSnapshotError(f"Image is too large: {len(data)} > {MAX_IMAGE_BYTES}") + + +def _persist(data: bytes, *, ext: str, session_dir: Path) -> Path: + attachments_dir = session_dir / "attachments" + attachments_dir.mkdir(parents=True, exist_ok=True) + + digest = hashlib.sha1(data, usedforsecurity=False).hexdigest() + dest = attachments_dir / f"{digest}{ext}" + if not dest.exists(): + dest.write_bytes(data) + return dest.resolve() + + def snapshot_image( source: Path, *, alias: str, session_dir: Path | None ) -> ImageAttachment: @@ -39,18 +73,44 @@ def snapshot_image( except OSError as e: raise ImageSnapshotError(f"Failed to read image {source_abs}: {e}") from e + _check_size(data) + # Session logging disabled: no snapshot copy is made; the attachment # points to the original source. Resume is not possible in this mode so # snapshot stability is not required. if session_dir is None: - return ImageAttachment(path=source_abs, alias=alias, mime_type=mime_type) + return ImageAttachment( + source=FileImageSource(path=source_abs), alias=alias, mime_type=mime_type + ) - attachments_dir = session_dir / "attachments" - attachments_dir.mkdir(parents=True, exist_ok=True) + return ImageAttachment( + source=FileImageSource(path=_persist(data, ext=ext, session_dir=session_dir)), + alias=alias, + mime_type=mime_type, + ) - digest = hashlib.sha1(data, usedforsecurity=False).hexdigest() - dest = attachments_dir / f"{digest}{ext}" - if not dest.exists(): - dest.write_bytes(data) - return ImageAttachment(path=dest.resolve(), alias=alias, mime_type=mime_type) +def snapshot_image_bytes( + data: bytes, *, alias: str, mime_type: str, session_dir: Path | None +) -> ImageAttachment: + ext = extension_for_mime(mime_type) + if ext is None: + raise ImageSnapshotError(f"Unsupported image mime type: {mime_type}") + + _check_size(data) + + # No session dir means logging is disabled and nothing should be written to + # disk: keep the bytes inline so the attachment outlives this call without a + # dangling file path. + if session_dir is None: + return ImageAttachment( + source=InlineImageSource(data=base64.b64encode(data).decode("ascii")), + alias=alias, + mime_type=mime_type, + ) + + return ImageAttachment( + source=FileImageSource(path=_persist(data, ext=ext, session_dir=session_dir)), + alias=alias, + mime_type=mime_type, + ) diff --git a/vibe/core/session/resume_sessions.py b/vibe/core/session/resume_sessions.py index a2b6eb4..98f67d5 100644 --- a/vibe/core/session/resume_sessions.py +++ b/vibe/core/session/resume_sessions.py @@ -1,70 +1,26 @@ from __future__ import annotations -import asyncio -from collections.abc import Callable, Sequence -import contextlib from dataclasses import dataclass -from datetime import datetime -from typing import Literal, NamedTuple, Protocol from vibe.core.config import VibeConfig -from vibe.core.logger import logger -from vibe.core.nuage.client import WorkflowsClient -from vibe.core.nuage.workflow import ( - WorkflowExecutionListResponse, - WorkflowExecutionStatus, -) from vibe.core.session.session_id import shorten_session_id from vibe.core.session.session_loader import SessionLoader -ResumeSessionSource = Literal["local", "remote"] - -def can_delete_resume_session_source(source: ResumeSessionSource) -> bool: - return source == "local" - - -def short_session_id(session_id: str, source: ResumeSessionSource = "local") -> str: - return shorten_session_id(session_id, from_end=source == "remote") - - -_ACTIVE_STATUSES = [ - WorkflowExecutionStatus.RUNNING, - WorkflowExecutionStatus.CONTINUED_AS_NEW, -] - - -class RemoteWorkflowRunsClient(Protocol): - async def get_workflow_runs( - self, - workflow_identifier: str | None = None, - page_size: int = 50, - next_page_token: str | None = None, - status: Sequence[WorkflowExecutionStatus] | None = None, - user_id: str = "current", - ) -> WorkflowExecutionListResponse: ... - - -class RemoteResumeClient(RemoteWorkflowRunsClient, Protocol): - async def aclose(self) -> None: ... +def short_session_id(session_id: str) -> str: + return shorten_session_id(session_id) @dataclass(frozen=True) class ResumeSessionInfo: session_id: str - source: ResumeSessionSource cwd: str title: str | None end_time: str | None - status: str | None = None @property def option_id(self) -> str: - return f"{self.source}:{self.session_id}" - - @property - def can_delete(self) -> bool: - return can_delete_resume_session_source(self.source) + return self.session_id def list_local_resume_sessions( @@ -73,7 +29,6 @@ def list_local_resume_sessions( return [ ResumeSessionInfo( session_id=session["session_id"], - source="local", cwd=session["cwd"], title=session.get("title"), end_time=session.get("end_time"), @@ -82,50 +37,11 @@ def list_local_resume_sessions( ] -async def list_remote_resume_sessions( - client: RemoteWorkflowRunsClient, workflow_id: str -) -> list[ResumeSessionInfo]: - response = await client.get_workflow_runs( - workflow_identifier=workflow_id, page_size=50, status=_ACTIVE_STATUSES - ) - - seen: dict[str, ResumeSessionInfo] = {} - latest_start: dict[str, datetime] = {} - for execution in response.executions: - session = ResumeSessionInfo( - session_id=execution.execution_id, - source="remote", - cwd="", - title="Vibe Code", - end_time=( - execution.end_time.isoformat() - if execution.end_time - else execution.start_time.isoformat() - ), - status=execution.status, - ) - prev_start = latest_start.get(execution.execution_id) - if prev_start is None or execution.start_time > prev_start: - seen[execution.execution_id] = session - latest_start[execution.execution_id] = execution.start_time - - sessions = list(seen.values()) - - logger.debug("Remote resume listing filtered sessions: %d", len(sessions)) - return sessions - - def session_latest_messages( sessions: list[ResumeSessionInfo], config: VibeConfig ) -> dict[str, str]: messages: dict[str, str] = {} for session in sessions: - if session.source == "remote": - status = (session.status or "RUNNING").lower() - messages[session.option_id] = ( - f"{session.title or 'Remote workflow'} ({status})" - ) - continue messages[session.option_id] = ( session.title or SessionLoader.get_first_user_message( @@ -133,91 +49,3 @@ def session_latest_messages( ) ) return messages - - -class RemoteResumeResult(NamedTuple): - sessions: list[ResumeSessionInfo] - error: str | None - - -def _default_remote_resume_client(config: VibeConfig) -> RemoteResumeClient: - return WorkflowsClient( - base_url=config.vibe_code_base_url, - api_key=config.vibe_code_api_key, - timeout=config.api_timeout, - ) - - -class RemoteResumeSessions: - def __init__( - self, - get_config: Callable[[], VibeConfig], - client_factory: Callable[ - [VibeConfig], RemoteResumeClient - ] = _default_remote_resume_client, - ) -> None: - self._get_config = get_config - self._client_factory = client_factory - self._client: RemoteResumeClient | None = None - self._client_settings: tuple[str, str, float] | None = None - self._fetch_task: asyncio.Task[RemoteResumeResult] | None = None - - async def _reusable_client(self, config: VibeConfig) -> RemoteResumeClient: - settings = ( - config.vibe_code_base_url, - config.vibe_code_api_key, - config.api_timeout, - ) - if self._client is not None and self._client_settings == settings: - return self._client - if self._client is not None: - await self._close_client() - self._client = self._client_factory(config) - self._client_settings = settings - return self._client - - def start(self, timeout: float) -> asyncio.Task[RemoteResumeResult]: - if self._fetch_task is not None and not self._fetch_task.done(): - self._fetch_task.cancel() - self._fetch_task = asyncio.create_task(self.fetch(timeout)) - return self._fetch_task - - async def fetch(self, timeout: float) -> RemoteResumeResult: - config = self._get_config() - if not config.vibe_code_enabled or not config.vibe_code_api_key: - logger.debug( - "Remote resume listing skipped: missing Vibe Code configuration" - ) - return RemoteResumeResult([], None) - try: - client = await self._reusable_client(config) - sessions = await asyncio.wait_for( - list_remote_resume_sessions(client, config.vibe_code_workflow_id), - timeout=timeout, - ) - except TimeoutError: - return RemoteResumeResult( - [], f"Timed out while listing remote sessions after {timeout:.0f}s." - ) - except Exception as e: - return RemoteResumeResult([], f"Failed to list remote sessions: {e}") - return RemoteResumeResult(sessions, None) - - async def aclose(self) -> None: - if self._fetch_task is not None and not self._fetch_task.done(): - self._fetch_task.cancel() - with contextlib.suppress(asyncio.CancelledError, Exception): - await self._fetch_task - self._fetch_task = None - await self._close_client() - - async def _close_client(self) -> None: - if self._client is None: - return - try: - await self._client.aclose() - except Exception as exc: - logger.error("Failed to close resume workflows client", exc_info=exc) - finally: - self._client = None - self._client_settings = None diff --git a/vibe/core/skills/builtins/vibe.py b/vibe/core/skills/builtins/vibe.py index 8c945e2..1ca1b4d 100644 --- a/vibe/core/skills/builtins/vibe.py +++ b/vibe/core/skills/builtins/vibe.py @@ -70,7 +70,8 @@ Vibe never updates silently. With `enable_update_checks = true` (default), it polls PyPI for `mistral-vibe` daily and prompts on the next launch when a newer release exists; accepting runs `uv tool upgrade mistral-vibe`, then `brew upgrade mistral-vibe` as a fallback. Disable via `enable_update_checks -= false`. Initial install: `uv tool install mistral-vibe`. += false`. Run `vibe --check-upgrade` to check immediately, prompt to install a newer +version if one exists, and exit. Initial install: `uv tool install mistral-vibe`. ### Version @@ -92,8 +93,7 @@ current folder**: only sessions whose `cwd` matches where Vibe is launched are listed, so the same directory shows its own history and nothing else. Switch folders to see a different set. The explicit `--resume <SESSION_ID>` form is **not** folder-scoped: it resolves the session by id regardless of which folder -it ran in. When Vibe Code is enabled, active **remote** sessions are listed -alongside local ones in the picker (tagged `remote`) and are not folder-scoped. +it ran in. ## Configuration (config.toml) @@ -124,6 +124,7 @@ enable_update_checks = true # Daily PyPI check; prompts on next launch whe enable_notifications = true enable_system_trust_store = false # Use OS trust store for outbound HTTPS api_timeout = 720.0 # API request timeout in seconds +api_retry_max_elapsed_time = 300.0 # Retry budget for retryable API failures in seconds auto_compact_threshold = 200000 # Token count before auto-compaction # Git commit behavior @@ -270,8 +271,33 @@ name = "remote-server" transport = "http" url = "https://mcp.example.com" api_key_env = "MCP_API_KEY" + +[[mcp_servers]] +name = "linear" +transport = "streamable-http" +url = "https://mcp.linear.app/mcp" + +[mcp_servers.auth] +type = "oauth" +scopes = ["read", "write"] +# Optional: client_id = "pre-registered-public-client" +# Optional: client_metadata_url = "https://example.com/client-metadata.json" +# Optional: redirect_port = 47823 ``` +HTTP MCP servers can use either static auth or OAuth: + +- Static auth: legacy `api_key_env` / `headers` keys still work and are + promoted to `auth.type = "static"` internally. +- OAuth auth: use `auth.type = "oauth"` with `scopes`. Vibe stores tokens + in the OS keyring under `mcp-oauth:<alias>:tokens`, dynamic client info + under `mcp-oauth:<alias>:client_info`, and config drift fingerprints under + `mcp-oauth:<alias>:fingerprint`. +- Headless environments without an OS keyring cannot store OAuth tokens; use + static auth via `api_key_env` instead. +- For SSH/remote browser callbacks, forward the loopback port: + `ssh -L 47823:127.0.0.1:47823 <host>`. + ### Connectors Mistral connectors are auto-discovered when the active provider is Mistral @@ -496,8 +522,9 @@ Tool, skill, and agent names support three matching modes: vibe [PROMPT] # Start interactive session with optional prompt vibe -p TEXT / --prompt TEXT # Programmatic mode using `default_agent`, one-shot, exit vibe -p TEXT --auto-approve # Programmatic mode with all tool calls approved +vibe -p TEXT --yolo # Alias for `--auto-approve` vibe --agent NAME # Select agent profile (falls back to `default_agent` config) -vibe --auto-approve # Shortcut for `--agent auto-approve` +vibe --auto-approve / --yolo # Shortcut for `--agent auto-approve` vibe --workdir DIR # Change working directory vibe --add-dir DIR # Extra working dir loaded for context (repeatable). Implicitly trusted. vibe --trust # Trust cwd for this invocation only (not persisted) @@ -505,6 +532,7 @@ vibe -c / --continue # Continue most recent session in this termi vibe --resume [SESSION_ID] # Resume a specific session vibe -v / --version # Show version vibe --setup # Run onboarding/setup +vibe --check-upgrade # Check for a Vibe update now, prompt to install it, and exit vibe --max-turns N # Max assistant turns (programmatic mode) vibe --max-price DOLLARS # Max cost limit (programmatic mode) vibe --max-tokens N # Max total session tokens (programmatic mode) @@ -551,10 +579,13 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`. - `/status` - Display agent statistics - `/voice` - Configure voice settings - `/mcp` - Display available MCP servers (pass a server name to list its tools) +- `/mcp status` - Display MCP auth state (`ok`, `needs_auth`, `static`, `stdio`) +- `/mcp login <alias>` - Start OAuth login for an MCP server +- `/mcp logout <alias>` - Log out from an MCP server and delete stored OAuth + secrets - `/resume` (or `/continue`) - Browse and resume past sessions for the current - folder (plus active remote sessions when Vibe Code is enabled). The picker - header shows the folder being listed. Press `D` twice to delete a local saved - session; remote sessions and the active session cannot be deleted here. + folder. The picker header shows the folder being listed. Press `D` twice to + delete a saved session; the active session cannot be deleted here. - `/rewind` - Rewind to a previous message - `/loop <interval> <prompt>` - Schedule a recurring prompt (e.g. `/loop 30s ping`). Intervals: `Ns/Nm/Nh/Nd`, minimum 30s, max 50 loops/session. diff --git a/vibe/core/system_prompt.py b/vibe/core/system_prompt.py index eaff67a..460631d 100644 --- a/vibe/core/system_prompt.py +++ b/vibe/core/system_prompt.py @@ -139,8 +139,8 @@ class ProjectContextProvider: except Exception as e: return f"Error getting git status: {e}" - def get_full_context(self, *, include_git_status: bool = True) -> str: - git_status = self.get_git_status() if include_git_status else "" + def get_full_context(self) -> str: + git_status = self.get_git_status() template = UtilityPrompt.PROJECT_CONTEXT.read() return Template(template).safe_substitute( @@ -311,7 +311,6 @@ def get_universal_system_prompt( # noqa: PLR0912 skill_manager: SkillManager, agent_manager: AgentManager, *, - include_git_status: bool = True, scratchpad_dir: Path | None = None, headless: bool = False, experiment_manager: ExperimentManager | None = None, @@ -356,7 +355,7 @@ def get_universal_system_prompt( # noqa: PLR0912 else: context = ProjectContextProvider( config=config.project_context, root_path=Path.cwd() - ).get_full_context(include_git_status=include_git_status) + ).get_full_context() sections.append(context) diff --git a/vibe/core/telemetry/send.py b/vibe/core/telemetry/send.py index 2069a1f..07ef5ba 100644 --- a/vibe/core/telemetry/send.py +++ b/vibe/core/telemetry/send.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable -import os from pathlib import Path from typing import TYPE_CHECKING, Any, Literal from urllib.parse import urljoin @@ -10,7 +9,7 @@ from urllib.parse import urljoin import httpx from vibe import __version__ -from vibe.core.config import ProviderConfig, VibeConfig +from vibe.core.config import ProviderConfig, VibeConfig, resolve_api_key from vibe.core.llm.format import ResolvedToolCall from vibe.core.logger import logger from vibe.core.telemetry.build_metadata import build_base_metadata @@ -53,7 +52,7 @@ def get_mistral_provider_and_api_key( if provider is None: return None env_var = provider.api_key_env_var - api_key = os.getenv(env_var) if env_var else None + api_key = resolve_api_key(env_var) if api_key is None: return None return provider, api_key @@ -372,11 +371,6 @@ class TelemetryClient: correlation_id=self.last_correlation_id, ) - def send_remote_resume_requested(self, *, session_id: str) -> None: - self.send_telemetry_event( - "vibe.remote_resume_requested", {"session_id": session_id} - ) - def send_teleport_completed( self, *, push_required: bool, nb_session_messages: int ) -> None: diff --git a/vibe/core/telemetry/types.py b/vibe/core/telemetry/types.py index 0c8b86f..4a7629f 100644 --- a/vibe/core/telemetry/types.py +++ b/vibe/core/telemetry/types.py @@ -60,7 +60,7 @@ class TelemetryRequestMetadata(TelemetryBaseMetadata): TeleportFailureStage = Literal[ - "no_history", "remote_session", "git_check", "push", "workflow_start", "cancelled" + "no_history", "git_check", "push", "workflow_start", "cancelled" ] diff --git a/vibe/core/tools/base.py b/vibe/core/tools/base.py index 7db00c6..356a9b3 100644 --- a/vibe/core/tools/base.py +++ b/vibe/core/tools/base.py @@ -34,6 +34,7 @@ if TYPE_CHECKING: from vibe.core.hooks.models import HookConfigResult from vibe.core.skills.manager import SkillManager from vibe.core.telemetry.types import EntrypointMetadata, TerminalEmulator + from vibe.core.tools.mcp.pool import MCPConnectionPool from vibe.core.tools.mcp_sampling import MCPSamplingHandler from vibe.core.tools.permissions import PermissionContext, PermissionStore from vibe.core.types import ApprovalCallback, SwitchAgentCallback, UserInputCallback @@ -59,6 +60,7 @@ class InvokeContext: permission_store: PermissionStore | None = field(default=None) hook_config_result: HookConfigResult | None = field(default=None) session_id: str | None = field(default=None) + mcp_pool: MCPConnectionPool | None = field(default=None) terminal_emulator: TerminalEmulator | None = field(default=None) diff --git a/vibe/core/tools/builtins/edit.py b/vibe/core/tools/builtins/edit.py index e37c942..4c6eaab 100644 --- a/vibe/core/tools/builtins/edit.py +++ b/vibe/core/tools/builtins/edit.py @@ -26,7 +26,7 @@ from vibe.core.utils.io import ( file_write_lock, read_safe_async, ) -from vibe.core.utils.text import snippet_start_line +from vibe.core.utils.text import snippet_start_lines class EditArgs(BaseModel): @@ -47,11 +47,12 @@ class EditResult(BaseModel): old_string: str new_string: str # UI hint for the diff renderer; not part of the serialized result contract. - _ui_start_line: int | None = PrivateAttr(default=None) + # One entry per replaced occurrence (replace_all yields several). + _ui_start_lines: list[int] = PrivateAttr(default_factory=list) @property - def ui_start_line(self) -> int | None: - return self._ui_start_line + def ui_start_lines(self) -> list[int]: + return self._ui_start_lines class EditConfig(BaseToolConfig): @@ -145,7 +146,9 @@ class Edit( f"instance.\nString: {args.old_string}" ) - start_line = snippet_start_line(original, args.old_string) + start_lines = snippet_start_lines(original, args.old_string) + if not args.replace_all: + start_lines = start_lines[:1] modified = self._apply_edit( original, args.old_string, args.new_string, args.replace_all @@ -178,7 +181,7 @@ class Edit( old_string=args.old_string, new_string=args.new_string, ) - result._ui_start_line = start_line + result._ui_start_lines = start_lines yield result @final diff --git a/vibe/core/tools/builtins/read.py b/vibe/core/tools/builtins/read.py index 0d922e6..029ae27 100644 --- a/vibe/core/tools/builtins/read.py +++ b/vibe/core/tools/builtins/read.py @@ -67,6 +67,8 @@ class ReadResult(BaseModel): content: str num_lines: int start_line: int + requested_offset: int | None = None + requested_limit: int = DEFAULT_LINE_LIMIT total_lines: int | None = None was_truncated: bool = False @@ -178,6 +180,8 @@ class Read( content=content, num_lines=len(selected), start_line=start_line, + requested_offset=args.offset, + requested_limit=args.limit, total_lines=total_lines, was_truncated=was_truncated, ) diff --git a/vibe/core/tools/builtins/task.py b/vibe/core/tools/builtins/task.py index 8202095..6c9a2d6 100644 --- a/vibe/core/tools/builtins/task.py +++ b/vibe/core/tools/builtins/task.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import AsyncGenerator -from contextlib import aclosing +from contextlib import aclosing, suppress import fnmatch from typing import ClassVar @@ -186,6 +186,9 @@ class Task( turns_used = sum( msg.role == Role.assistant for msg in subagent_loop.messages ) + finally: + with suppress(Exception): + await subagent_loop.aclose() yield TaskResult( response="".join(accumulated_response), diff --git a/vibe/core/tools/builtins/websearch.py b/vibe/core/tools/builtins/websearch.py index 85b4ba3..4bef758 100644 --- a/vibe/core/tools/builtins/websearch.py +++ b/vibe/core/tools/builtins/websearch.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections.abc import AsyncGenerator -import os from typing import TYPE_CHECKING, ClassVar, final import httpx @@ -15,7 +14,7 @@ from mistralai.client.models import ( ) from pydantic import BaseModel, Field -from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, VibeConfig +from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, VibeConfig, resolve_api_key from vibe.core.tools.base import ( BaseTool, BaseToolConfig, @@ -67,13 +66,13 @@ class WebSearch( @classmethod def is_available(cls, config: VibeConfig | None = None) -> bool: if config is None: - return bool(os.getenv(DEFAULT_MISTRAL_API_ENV_KEY)) + return bool(resolve_api_key(DEFAULT_MISTRAL_API_ENV_KEY)) provider = config.get_mistral_provider() if provider is None: - return bool(os.getenv(DEFAULT_MISTRAL_API_ENV_KEY)) + return bool(resolve_api_key(DEFAULT_MISTRAL_API_ENV_KEY)) - return bool(os.getenv(cls._api_key_env_var(config))) + return bool(resolve_api_key(cls._api_key_env_var(config))) @final async def run( @@ -81,7 +80,7 @@ class WebSearch( ) -> AsyncGenerator[ToolStreamEvent | WebSearchResult, None]: config = self._resolve_config(ctx) api_key_env_var = self._api_key_env_var(config) - api_key = os.getenv(api_key_env_var) + api_key = resolve_api_key(api_key_env_var) if not api_key: raise ToolError(f"{api_key_env_var} environment variable not set.") diff --git a/vibe/core/tools/manager.py b/vibe/core/tools/manager.py index 8019f31..80fc579 100644 --- a/vibe/core/tools/manager.py +++ b/vibe/core/tools/manager.py @@ -296,6 +296,7 @@ class ToolManager: if self._mcp_integrated: return if not self._config.mcp_servers: + self._mcp_registry.sync_active_servers([]) return try: diff --git a/vibe/core/tools/mcp/__init__.py b/vibe/core/tools/mcp/__init__.py index 904cc32..97616b7 100644 --- a/vibe/core/tools/mcp/__init__.py +++ b/vibe/core/tools/mcp/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations -from vibe.core.tools.mcp.registry import MCPRegistry +from vibe.core.tools.mcp.pool import MCPConnectionPool +from vibe.core.tools.mcp.registry import AuthStatus, MCPRegistry from vibe.core.tools.mcp.tools import ( MCPToolResult, RemoteTool, @@ -17,6 +18,8 @@ from vibe.core.tools.mcp.tools import ( ) __all__ = [ + "AuthStatus", + "MCPConnectionPool", "MCPRegistry", "MCPToolResult", "RemoteTool", diff --git a/vibe/core/tools/mcp/pool.py b/vibe/core/tools/mcp/pool.py new file mode 100644 index 0000000..876bb18 --- /dev/null +++ b/vibe/core/tools/mcp/pool.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import asyncio +import contextlib +from dataclasses import dataclass +from datetime import timedelta +import hashlib +from typing import TYPE_CHECKING, Any + +import anyio + +from vibe.core.logger import logger +from vibe.core.tools.mcp.tools import ( + MCPToolResult, + _parse_call_result as parse_call_result, + build_stdio_params, + enter_stdio_session, +) + +if TYPE_CHECKING: + from mcp import ClientSession + from mcp.client.stdio import StdioServerParameters + from vibe.core.tools.mcp_sampling import MCPSamplingHandler + + +# Errors that indicate the stdio transport (subprocess / pipe) is gone and the +# session must be respawned. Tool-level errors and timeouts are deliberately +# excluded: they are legitimate server responses, not dead connections. +_TRANSPORT_ERRORS: tuple[type[BaseException], ...] = ( + anyio.ClosedResourceError, + anyio.BrokenResourceError, + BrokenPipeError, + ConnectionError, + EOFError, +) + +# How long aclose waits for a connection's worker to drain and shut its session +# down gracefully before cancelling it. +_CLOSE_TIMEOUT_SEC = 5.0 + + +def stdio_key(command: list[str], env: dict[str, str] | None, cwd: str | None) -> str: + # \0 and \x01 delimit the three identity fields so distinct inputs cannot + # collide (e.g. ["a b"] vs ["a", "b"], or command vs env boundaries). + env_part = "\0".join(f"{k}={v}" for k, v in sorted((env or {}).items())) + raw = "\0".join(command) + "\x01" + env_part + "\x01" + (cwd or "") + return hashlib.blake2s(raw.encode("utf-8"), digest_size=16).hexdigest() + + +@dataclass +class _Request: + tool_name: str + arguments: dict[str, Any] + call_timeout: timedelta | None + future: asyncio.Future[Any] + + +class _StdioConnection: + """A single long-lived stdio MCP session owned by one dedicated task. + + The MCP ``stdio_client`` and ``ClientSession`` context managers open anyio + task groups bound to the task that enters them, so the session must be + entered, used, and exited all within the same task. A single worker task + owns the session for its whole lifetime and services calls from a queue; + callers submit a request and await its future. Because the worker handles + one request at a time, calls to the same server are serialized (stateful + servers never see interleaved requests). On transport death the worker drops + the session and respawns it once before retrying the call. + """ + + def __init__( + self, + params: StdioServerParameters, + init_timeout: timedelta | None, + sampling_callback: MCPSamplingHandler | None, + ) -> None: + self._params = params + self._init_timeout = init_timeout + self._sampling_callback = sampling_callback + self._requests: asyncio.Queue[_Request | None] = asyncio.Queue() + self._worker: asyncio.Task[None] | None = None + self._session: ClientSession | None = None + self._stack: contextlib.AsyncExitStack | None = None + self._inflight: _Request | None = None + + def _ensure_worker(self) -> None: + if self._worker is None or self._worker.done(): + self._worker = asyncio.create_task(self._run()) + + async def call_tool( + self, tool_name: str, arguments: dict[str, Any], call_timeout: timedelta | None + ) -> Any: + self._ensure_worker() + future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + await self._requests.put(_Request(tool_name, arguments, call_timeout, future)) + return await future + + async def _run(self) -> None: + try: + while True: + req = await self._requests.get() + if req is None: + return + self._inflight = req + try: + result = await self._handle(req) + except Exception as exc: + if not req.future.done(): + req.future.set_exception(exc) + self._inflight = None + else: + if not req.future.done(): + req.future.set_result(result) + self._inflight = None + finally: + await self._close_session() + self._fail_pending() + + async def _handle(self, req: _Request) -> Any: + session = await self._ensure_session() + try: + return await session.call_tool( + req.tool_name, req.arguments, read_timeout_seconds=req.call_timeout + ) + except _TRANSPORT_ERRORS as exc: + logger.debug("MCP stdio transport died, reconnecting once: %r", exc) + await self._close_session() + session = await self._ensure_session() + return await session.call_tool( + req.tool_name, req.arguments, read_timeout_seconds=req.call_timeout + ) + + async def _ensure_session(self) -> ClientSession: + if self._session is not None: + return self._session + stack = contextlib.AsyncExitStack() + try: + session = await enter_stdio_session( + stack, + self._params, + init_timeout=self._init_timeout, + sampling_callback=self._sampling_callback, + ) + except BaseException: + await stack.aclose() + raise + self._stack = stack + self._session = session + return session + + async def _close_session(self) -> None: + stack, self._stack = self._stack, None + self._session = None + if stack is not None: + with contextlib.suppress(Exception): + await stack.aclose() + + def _fail_pending(self) -> None: + err = RuntimeError("MCP stdio connection closed") + if self._inflight is not None and not self._inflight.future.done(): + self._inflight.future.set_exception(err) + self._inflight = None + while not self._requests.empty(): + try: + req = self._requests.get_nowait() + except asyncio.QueueEmpty: + break + if req is not None and not req.future.done(): + req.future.set_exception(err) + + async def aclose(self) -> None: + worker = self._worker + self._worker = None + if worker is None or worker.done(): + return + await self._requests.put(None) + try: + await asyncio.wait_for(worker, _CLOSE_TIMEOUT_SEC) + except Exception as exc: + logger.debug("MCP stdio worker shutdown error: %r", exc) + if not worker.done(): + worker.cancel() + with contextlib.suppress(BaseException): + await worker + + +class MCPConnectionPool: + """Session-scoped pool of persistent stdio MCP connections. + + Owned by an ``AgentLoop`` and created lazily in that loop's event loop on the + first call (discovery runs in a throwaway loop, so connections must not be + shared with it). Connections live until ``aclose`` is called at session end. + """ + + def __init__(self) -> None: + self._conns: dict[str, _StdioConnection] = {} + self._creation_lock = asyncio.Lock() + self._loop: asyncio.AbstractEventLoop | None = None + + def _bind_loop(self) -> None: + loop = asyncio.get_running_loop() + if self._loop is loop: + return + if self._loop is not None: + # Running under a different loop than the cached connections were + # created on: those connections (and their worker tasks) are dead + # here and cannot be closed on a loop that is no longer running. + logger.debug( + "MCP pool bound to a new event loop; dropping %d stale connection(s)", + len(self._conns), + ) + self._conns.clear() + self._loop = loop + + async def _get_or_create( + self, + key: str, + command: list[str], + env: dict[str, str] | None, + cwd: str | None, + startup_timeout_sec: float | None, + sampling_callback: MCPSamplingHandler | None, + ) -> _StdioConnection: + if (conn := self._conns.get(key)) is not None: + return conn + async with self._creation_lock: + if (conn := self._conns.get(key)) is not None: + return conn + params = build_stdio_params(command, env=env, cwd=cwd) + init_timeout = ( + timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None + ) + conn = _StdioConnection(params, init_timeout, sampling_callback) + self._conns[key] = conn + return conn + + async def call_tool( + self, + *, + command: list[str], + tool_name: str, + arguments: dict[str, Any], + env: dict[str, str] | None = None, + cwd: str | None = None, + startup_timeout_sec: float | None = None, + tool_timeout_sec: float | None = None, + sampling_callback: MCPSamplingHandler | None = None, + ) -> MCPToolResult: + self._bind_loop() + key = stdio_key(command, env, cwd) + conn = await self._get_or_create( + key, command, env, cwd, startup_timeout_sec, sampling_callback + ) + call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None + result = await conn.call_tool(tool_name, arguments, call_timeout) + return parse_call_result("stdio:" + " ".join(command), tool_name, result) + + async def aclose(self) -> None: + conns = list(self._conns.values()) + self._conns.clear() + self._loop = None + await asyncio.gather(*(conn.aclose() for conn in conns), return_exceptions=True) diff --git a/vibe/core/tools/mcp/registry.py b/vibe/core/tools/mcp/registry.py index 5313022..cdfd372 100644 --- a/vibe/core/tools/mcp/registry.py +++ b/vibe/core/tools/mcp/registry.py @@ -1,12 +1,26 @@ from __future__ import annotations import asyncio +from collections.abc import Awaitable, Callable +from contextlib import suppress +from enum import StrEnum, auto import hashlib -from typing import TYPE_CHECKING, cast +from typing import cast +from mcp.client.auth import OAuthFlowError +from vibe.core.auth.mcp_oauth import ( + Fingerprint, + KeyringTokenStorage, + MCPOAuthError, + MCPOAuthLoginFailed, + build_oauth_provider, + perform_oauth_login, +) +from vibe.core.config import MCPHttp, MCPOAuth, MCPServer, MCPStdio, MCPStreamableHttp from vibe.core.logger import logger from vibe.core.tools.base import BaseTool from vibe.core.tools.mcp.tools import ( + MCPHttpOAuthRuntime, create_mcp_http_proxy_tool_class, create_mcp_stdio_proxy_tool_class, list_tools_http, @@ -14,8 +28,12 @@ from vibe.core.tools.mcp.tools import ( ) from vibe.core.utils import run_sync -if TYPE_CHECKING: - from vibe.core.config import MCPHttp, MCPServer, MCPStdio, MCPStreamableHttp + +class AuthStatus(StrEnum): + OK = auto() + NEEDS_AUTH = auto() + STATIC = auto() + STDIO = auto() class MCPRegistry: @@ -28,6 +46,10 @@ class MCPRegistry: def __init__(self) -> None: self._cache: dict[str, dict[str, type[BaseTool]]] = {} + self._cache_keys_by_alias: dict[str, set[str]] = {} + self._servers_by_alias: dict[str, MCPServer] = {} + self._needs_auth: set[str] = set() + self._oauth_locks: dict[str, asyncio.Lock] = {} @staticmethod def _server_key(srv: MCPServer) -> str: @@ -45,6 +67,7 @@ class MCPRegistry: result: dict[str, type[BaseTool]] = {} to_discover: list[tuple[str, MCPServer]] = [] + self.sync_active_servers(servers) for srv in servers: key = self._server_key(srv) if key in self._cache: @@ -73,10 +96,20 @@ class MCPRegistry: continue if result is None: continue - self._cache[key] = result + self._store_cache_entry(key, srv.name, result) out.update(result) return out + def _store_cache_entry( + self, key: str, alias: str, tools: dict[str, type[BaseTool]] + ) -> None: + self._cache[key] = tools + self._cache_keys_by_alias.setdefault(alias, set()).add(key) + + def _drop_alias_cache(self, alias: str) -> None: + for key in self._cache_keys_by_alias.pop(alias, set()): + self._cache.pop(key, None) + async def _discover_server( self, srv: MCPServer ) -> dict[str, type[BaseTool]] | None: @@ -99,12 +132,10 @@ class MCPRegistry: logger.warning("MCP server '%s' missing url for http transport", srv.name) return {} - if srv.auth.type == "oauth": - logger.warning( - "OAuth support for MCP servers is not yet enabled; coming in a future release" - ) - return {} + if isinstance(srv.auth, MCPOAuth): + return await self._discover_oauth_http(srv, url=url) + self._needs_auth.discard(srv.name) headers = srv.http_headers() try: remotes = await list_tools_http( @@ -137,6 +168,91 @@ class MCPRegistry: ) return tools + async def _discover_oauth_http( + self, srv: MCPHttp | MCPStreamableHttp, *, url: str + ) -> dict[str, type[BaseTool]] | None: + alias = srv.name + self._servers_by_alias[alias] = srv + lock = self.oauth_lock_for(alias) + + try: + storage = KeyringTokenStorage(alias=alias) + current_fingerprint = Fingerprint.compute(srv) + saved_fingerprint = await Fingerprint.load(alias) + tokens = await storage.get_tokens() + except MCPOAuthError as exc: + logger.warning("%s", exc) + self.mark_needs_auth(alias) + return None + + if saved_fingerprint != current_fingerprint: + await storage.delete_tokens() + await storage.delete_client_info() + await Fingerprint.delete(alias) + self._drop_alias_cache(alias) + self.mark_needs_auth(alias) + return None + + if tokens is None: + self.mark_needs_auth(alias) + return None + + async def redirect_handler(_url: str) -> None: + raise OAuthFlowError( + f"MCP server {alias!r} requires interactive OAuth login" + ) + + async def callback_handler() -> tuple[str, str | None]: + raise OAuthFlowError( + f"MCP server {alias!r} requires interactive OAuth login" + ) + + provider = build_oauth_provider( + srv, redirect_handler=redirect_handler, callback_handler=callback_handler + ) + try: + remotes = await list_tools_http( + url, + headers=srv.http_headers(), + auth=provider, + startup_timeout_sec=srv.startup_timeout_sec, + ) + except OAuthFlowError as exc: + await self.mark_oauth_failure(alias) + logger.warning("MCP OAuth discovery failed for %s: %s", alias, exc) + return None + except Exception as exc: + logger.warning("MCP HTTP discovery failed for %s: %s", url, exc) + return None + + self._needs_auth.discard(alias) + tools: dict[str, type[BaseTool]] = {} + for remote in remotes: + try: + proxy_cls = create_mcp_http_proxy_tool_class( + url=url, + remote=remote, + alias=alias, + server_hint=srv.prompt, + headers=srv.http_headers(), + auth=provider, + oauth_runtime=MCPHttpOAuthRuntime( + lock=lock, failure_callback=self.mark_oauth_failure + ), + startup_timeout_sec=srv.startup_timeout_sec, + tool_timeout_sec=srv.tool_timeout_sec, + sampling_enabled=srv.sampling_enabled, + ) + tools[proxy_cls.get_name()] = proxy_cls + except Exception as exc: + logger.warning( + "Failed to register MCP HTTP tool '%s' from %s: %r", + getattr(remote, "name", "<unknown>"), + url, + exc, + ) + return tools + async def _discover_stdio(self, srv: MCPStdio) -> dict[str, type[BaseTool]] | None: cmd = srv.argv() if not cmd: @@ -185,3 +301,92 @@ class MCPRegistry: def clear(self) -> None: """Drop all cached entries, forcing re-discovery on next use.""" self._cache.clear() + self._cache_keys_by_alias.clear() + self._servers_by_alias.clear() + self._needs_auth.clear() + + def sync_active_servers(self, servers: list[MCPServer]) -> None: + active = {srv.name: srv for srv in servers} + self._servers_by_alias = active + active_oauth_aliases = { + srv.name + for srv in servers + if srv.transport in {"http", "streamable-http"} + and isinstance(cast("MCPHttp | MCPStreamableHttp", srv).auth, MCPOAuth) + } + self._needs_auth.intersection_update(active_oauth_aliases) + + @property + def needs_auth(self) -> set[str]: + return set(self._needs_auth) + + def mark_needs_auth(self, alias: str) -> None: + self._drop_alias_cache(alias) + self._needs_auth.add(alias) + + async def mark_oauth_failure(self, alias: str) -> None: + with suppress(MCPOAuthError): + await KeyringTokenStorage(alias=alias).delete_tokens() + self.mark_needs_auth(alias) + + def oauth_lock_for(self, alias: str) -> asyncio.Lock: + if alias not in self._oauth_locks: + self._oauth_locks[alias] = asyncio.Lock() + return self._oauth_locks[alias] + + def status(self) -> dict[str, AuthStatus]: + statuses: dict[str, AuthStatus] = {} + for alias, srv in self._servers_by_alias.items(): + match srv.transport: + case "stdio": + statuses[alias] = AuthStatus.STDIO + case "http" | "streamable-http": + if isinstance(srv.auth, MCPOAuth): + statuses[alias] = ( + AuthStatus.NEEDS_AUTH + if alias in self._needs_auth + or self._server_key(srv) not in self._cache + else AuthStatus.OK + ) + else: + statuses[alias] = AuthStatus.STATIC + return statuses + + def _require_oauth_server(self, alias: str) -> MCPHttp | MCPStreamableHttp: + srv = self._servers_by_alias.get(alias) + if srv is None: + raise ValueError(f"Unknown MCP server: {alias}") + if srv.transport not in {"http", "streamable-http"}: + raise ValueError(f"MCP server {alias!r} does not use HTTP transport") + http_srv = cast("MCPHttp | MCPStreamableHttp", srv) + if not isinstance(http_srv.auth, MCPOAuth): + raise ValueError(f"MCP server {alias!r} is not configured for OAuth") + return http_srv + + async def login( + self, alias: str, *, on_url: Callable[[str], Awaitable[None]] + ) -> None: + srv = self._require_oauth_server(alias) + async with self.oauth_lock_for(alias): + await perform_oauth_login(srv, on_url=on_url) + self._drop_alias_cache(alias) + tools = await self._discover_server(srv) + if tools is None: + self.mark_needs_auth(alias) + raise MCPOAuthLoginFailed( + server_alias=alias, + reason="login completed but tool discovery failed", + ) + self._needs_auth.discard(alias) + self._store_cache_entry(self._server_key(srv), alias, tools) + + async def logout(self, alias: str) -> None: + srv = self._require_oauth_server(alias) + async with self.oauth_lock_for(alias): + storage = KeyringTokenStorage(alias=alias) + await storage.delete_tokens() + await storage.delete_client_info() + await Fingerprint.delete(alias) + self._drop_alias_cache(alias) + self.mark_needs_auth(alias) + self._servers_by_alias[alias] = srv diff --git a/vibe/core/tools/mcp/tools.py b/vibe/core/tools/mcp/tools.py index bd351ff..1f6cbcb 100644 --- a/vibe/core/tools/mcp/tools.py +++ b/vibe/core/tools/mcp/tools.py @@ -1,7 +1,9 @@ from __future__ import annotations -from collections.abc import AsyncGenerator +import asyncio +from collections.abc import AsyncGenerator, Awaitable, Callable import contextlib +from dataclasses import dataclass from datetime import timedelta import hashlib import os @@ -13,6 +15,7 @@ import httpx from pydantic import BaseModel, ConfigDict, Field, field_validator from mcp import ClientSession +from mcp.client.auth import OAuthFlowError from mcp.client.stdio import StdioServerParameters, stdio_client from mcp.client.streamable_http import streamable_http_client from vibe.core.logger import logger @@ -138,6 +141,12 @@ class RemoteTool(BaseModel): return v +@dataclass(frozen=True) +class MCPHttpOAuthRuntime: + lock: asyncio.Lock + failure_callback: Callable[[str], Awaitable[None]] + + class _MCPContentBlock(BaseModel): model_config = ConfigDict(from_attributes=True) text: str | None = None @@ -252,6 +261,7 @@ def create_mcp_http_proxy_tool_class( server_hint: str | None = None, headers: dict[str, str] | None = None, auth: httpx.Auth | None = None, + oauth_runtime: MCPHttpOAuthRuntime | None = None, startup_timeout_sec: float | None = None, tool_timeout_sec: float | None = None, sampling_enabled: bool = True, @@ -278,10 +288,8 @@ def create_mcp_http_proxy_tool_class( _remote_name: ClassVar[str] = remote.name _input_schema: ClassVar[dict[str, Any]] = remote.input_schema _headers: ClassVar[dict[str, str]] = dict(headers or {}) - # TODO(VIBE-3057+): concurrent refresh coordinated by per-alias - # asyncio.Lock in MCPRegistry (PR 4 / project decision #6) — this - # object is shared across all calls on this proxy class. _auth: ClassVar[httpx.Auth | None] = auth + _oauth_runtime: ClassVar[MCPHttpOAuthRuntime | None] = oauth_runtime _startup_timeout_sec: ClassVar[float | None] = startup_timeout_sec _tool_timeout_sec: ClassVar[float | None] = tool_timeout_sec _sampling_enabled: ClassVar[bool] = sampling_enabled @@ -302,19 +310,38 @@ def create_mcp_http_proxy_tool_class( ctx.sampling_callback if ctx and self._sampling_enabled else None ) payload = args.model_dump(exclude_none=True) - yield await call_tool_http( - self._mcp_url, - self._remote_name, - payload, - headers=self._headers, - auth=self._auth, - startup_timeout_sec=self._startup_timeout_sec, - tool_timeout_sec=self._tool_timeout_sec, - sampling_callback=sampling_callback, - ) + if self._oauth_runtime is None: + yield await self._call_remote(payload, sampling_callback) + return + async with self._oauth_runtime.lock: + result = await self._call_remote(payload, sampling_callback) + yield result + except OAuthFlowError as exc: + if self._oauth_runtime is not None: + await self._oauth_runtime.failure_callback(self._server_name) + raise ToolError( + f"MCP server '{self._server_name}' lost authentication. " + "Stop the current turn and ask the user to run " + f"`/mcp login {self._server_name}` to re-authenticate." + ) from exc except Exception as exc: raise ToolError(f"MCP call failed: {exc}") from exc + @classmethod + async def _call_remote( + cls, payload: dict[str, Any], sampling_callback: MCPSamplingHandler | None + ) -> MCPToolResult: + return await call_tool_http( + cls._mcp_url, + cls._remote_name, + payload, + headers=cls._headers, + auth=cls._auth, + startup_timeout_sec=cls._startup_timeout_sec, + tool_timeout_sec=cls._tool_timeout_sec, + sampling_callback=sampling_callback, + ) + @classmethod def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: if not isinstance(event.result, MCPToolResult): @@ -334,6 +361,39 @@ def create_mcp_http_proxy_tool_class( return MCPHttpProxyTool +def build_stdio_params( + command: list[str], *, env: dict[str, str] | None = None, cwd: str | None = None +) -> StdioServerParameters: + return StdioServerParameters(command=command[0], args=command[1:], env=env, cwd=cwd) + + +async def enter_stdio_session( + stack: contextlib.AsyncExitStack, + params: StdioServerParameters, + *, + init_timeout: timedelta | None, + sampling_callback: MCPSamplingHandler | None = None, +) -> ClientSession: + """Enter the stderr-capture, stdio_client, and ClientSession contexts on *stack*. + + The caller owns ``stack`` and decides when to close it. Returns an initialized + session. The one-shot helpers close the stack immediately; the connection pool + keeps it open for the session lifetime. + """ + errlog = await stack.enter_async_context(_mcp_stderr_capture()) + read, write = await stack.enter_async_context(stdio_client(params, errlog=errlog)) + session = await stack.enter_async_context( + ClientSession( + read, + write, + read_timeout_seconds=init_timeout, + sampling_callback=sampling_callback, + ) + ) + await session.initialize() + return session + + async def list_tools_stdio( command: list[str], *, @@ -341,16 +401,10 @@ async def list_tools_stdio( cwd: str | None = None, startup_timeout_sec: float | None = None, ) -> list[RemoteTool]: - params = StdioServerParameters( - command=command[0], args=command[1:], env=env, cwd=cwd - ) + params = build_stdio_params(command, env=env, cwd=cwd) timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None - async with ( - _mcp_stderr_capture() as errlog, - stdio_client(params, errlog=errlog) as (read, write), - ClientSession(read, write, read_timeout_seconds=timeout) as session, - ): - await session.initialize() + async with contextlib.AsyncExitStack() as stack: + session = await enter_stdio_session(stack, params, init_timeout=timeout) tools_resp = await session.list_tools() return [RemoteTool.model_validate(t) for t in tools_resp.tools] @@ -366,24 +420,18 @@ async def call_tool_stdio( tool_timeout_sec: float | None = None, sampling_callback: MCPSamplingHandler | None = None, ) -> MCPToolResult: - params = StdioServerParameters( - command=command[0], args=command[1:], env=env, cwd=cwd - ) + params = build_stdio_params(command, env=env, cwd=cwd) init_timeout = ( timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None ) call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None - async with ( - _mcp_stderr_capture() as errlog, - stdio_client(params, errlog=errlog) as (read, write), - ClientSession( - read, - write, - read_timeout_seconds=init_timeout, + async with contextlib.AsyncExitStack() as stack: + session = await enter_stdio_session( + stack, + params, + init_timeout=init_timeout, sampling_callback=sampling_callback, - ) as session, - ): - await session.initialize() + ) result = await session.call_tool( tool_name, arguments, read_timeout_seconds=call_timeout ) @@ -447,7 +495,20 @@ def create_mcp_stdio_proxy_tool_class( ctx.sampling_callback if ctx and self._sampling_enabled else None ) payload = args.model_dump(exclude_none=True) - result = await call_tool_stdio( + pool = ctx.mcp_pool if ctx else None + if pool is not None: + yield await pool.call_tool( + command=self._stdio_command, + tool_name=self._remote_name, + arguments=payload, + env=self._env, + cwd=self._cwd, + startup_timeout_sec=self._startup_timeout_sec, + tool_timeout_sec=self._tool_timeout_sec, + sampling_callback=sampling_callback, + ) + return + yield await call_tool_stdio( self._stdio_command, self._remote_name, payload, @@ -457,7 +518,6 @@ def create_mcp_stdio_proxy_tool_class( tool_timeout_sec=self._tool_timeout_sec, sampling_callback=sampling_callback, ) - yield result except Exception as exc: raise ToolError(f"MCP stdio call failed: {exc!r}") from exc diff --git a/vibe/core/transcribe/mistral_transcribe_client.py b/vibe/core/transcribe/mistral_transcribe_client.py index d091197..3e3d7ff 100644 --- a/vibe/core/transcribe/mistral_transcribe_client.py +++ b/vibe/core/transcribe/mistral_transcribe_client.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections.abc import AsyncIterator -import os import httpx from mistralai.client import Mistral @@ -14,7 +13,11 @@ from mistralai.client.models import ( ) from mistralai.extra.realtime import UnknownRealtimeEvent -from vibe.core.config import TranscribeModelConfig, TranscribeProviderConfig +from vibe.core.config import ( + TranscribeModelConfig, + TranscribeProviderConfig, + resolve_api_key, +) from vibe.core.transcribe.transcribe_client_port import ( TranscribeDone, TranscribeError, @@ -29,7 +32,7 @@ class MistralTranscribeClient: def __init__( self, provider: TranscribeProviderConfig, model: TranscribeModelConfig ) -> None: - self._api_key = os.getenv(provider.api_key_env_var, "") + self._api_key = resolve_api_key(provider.api_key_env_var) or "" self._server_url = provider.api_base self._model_name = model.name self._audio_format = AudioFormat( diff --git a/vibe/core/trusted_folders.py b/vibe/core/trusted_folders.py index f4d62f0..31ea28b 100644 --- a/vibe/core/trusted_folders.py +++ b/vibe/core/trusted_folders.py @@ -22,6 +22,12 @@ class WorkspaceTrustDecision(StrEnum): DECLINE = "decline" +class WorkspaceTrustStatus(StrEnum): + TRUSTED = "trusted" + SESSION = "session" + UNTRUSTED = "untrusted" + + @dataclass(frozen=True) class WorkspaceTrustPrompt: cwd: Path @@ -110,14 +116,19 @@ def find_repo_trustable_files_for_cwd(cwd: Path, repo_root: Path | None) -> list return sorted(found) -def maybe_build_workspace_trust_prompt(cwd: Path) -> WorkspaceTrustPrompt | None: +def maybe_build_workspace_trust_prompt( + cwd: Path, *, include_explicitly_untrusted: bool = False +) -> WorkspaceTrustPrompt | None: resolved_cwd = cwd.resolve() if resolved_cwd == Path.home().resolve(): return None if trusted_folders_manager.is_trusted(cwd) is True: return None - if trusted_folders_manager.is_explicitly_untrusted(cwd): + if ( + not include_explicitly_untrusted + and trusted_folders_manager.is_explicitly_untrusted(cwd) + ): return None repo_root = find_git_repo_ancestor(cwd) @@ -131,7 +142,10 @@ def maybe_build_workspace_trust_prompt(cwd: Path) -> WorkspaceTrustPrompt | None resolved_repo_root is not None and resolved_repo_root in resolved_cwd.parents and trusted_folders_manager.is_trusted(resolved_repo_root) is not True - and not trusted_folders_manager.is_explicitly_untrusted(resolved_repo_root) + and ( + include_explicitly_untrusted + or not trusted_folders_manager.is_explicitly_untrusted(resolved_repo_root) + ) ) repo_explicitly_untrusted = ( resolved_repo_root is not None @@ -238,6 +252,20 @@ class TrustedFoldersManager: case None: return None + def trust_status(self, path: Path) -> WorkspaceTrustStatus: + current = Path(self._normalize_path(path)) + while True: + s = str(current) + if s in self._session_trusted: + return WorkspaceTrustStatus.SESSION + if s in self._trusted: + return WorkspaceTrustStatus.TRUSTED + if s in self._untrusted: + return WorkspaceTrustStatus.UNTRUSTED + if current.parent == current: + return WorkspaceTrustStatus.UNTRUSTED + current = current.parent + def is_explicitly_untrusted(self, path: Path) -> bool: """*path* literally in the untrusted list (no ancestor walk).""" return self._normalize_path(path) in self._untrusted diff --git a/vibe/core/tts/mistral_tts_client.py b/vibe/core/tts/mistral_tts_client.py index f39d467..9f61ee1 100644 --- a/vibe/core/tts/mistral_tts_client.py +++ b/vibe/core/tts/mistral_tts_client.py @@ -1,20 +1,19 @@ from __future__ import annotations import base64 -import os import httpx from mistralai.client import Mistral from mistralai.client.models import SpeechOutputFormat -from vibe.core.config import TTSModelConfig, TTSProviderConfig +from vibe.core.config import TTSModelConfig, TTSProviderConfig, resolve_api_key from vibe.core.tts.tts_client_port import TTSResult from vibe.core.utils.http import build_ssl_context class MistralTTSClient: def __init__(self, provider: TTSProviderConfig, model: TTSModelConfig) -> None: - self._api_key = os.getenv(provider.api_key_env_var, "") + self._api_key = resolve_api_key(provider.api_key_env_var) or "" self._server_url = provider.api_base self._model_name = model.name self._voice = model.voice diff --git a/vibe/core/types.py b/vibe/core/types.py index 47eb1b0..2cbc548 100644 --- a/vibe/core/types.py +++ b/vibe/core/types.py @@ -21,8 +21,10 @@ from pydantic import ( BeforeValidator, ConfigDict, Field, + JsonValue, PrivateAttr, computed_field, + field_validator, model_validator, ) @@ -221,14 +223,72 @@ IMAGE_EXTENSIONS: frozenset[str] = frozenset({".png", ".jpg", ".jpeg", ".gif", " MAX_IMAGE_BYTES: int = 10 * 1024 * 1024 MAX_IMAGES_PER_MESSAGE: int = 8 +type UserDisplayContentItem = dict[str, JsonValue] + + +class UserDisplayContentMetadata(BaseModel): + model_config = ConfigDict(allow_inf_nan=False, extra="forbid") + + version: str = Field(min_length=1) + host: str = Field(min_length=1) + content: list[UserDisplayContentItem] + + @field_validator("version") + @classmethod + def validate_version(cls, value: str) -> str: + version = value.strip() + if not version: + raise ValueError("version must not be blank") + + return version + + @field_validator("host") + @classmethod + def validate_host(cls, value: str) -> str: + host = value.strip() + if not host: + raise ValueError("host must not be blank") + + return host + + +class FileImageSource(BaseModel): + model_config = ConfigDict(extra="ignore") + + kind: Literal["file"] = "file" + path: Path + + +class InlineImageSource(BaseModel): + model_config = ConfigDict(extra="ignore") + + kind: Literal["inline"] = "inline" + # Raw base64-encoded bytes (no `data:` prefix). Used when the image has no + # durable file on disk (session logging disabled): memory-only, never + # persisted to a session transcript. + data: str + class ImageAttachment(BaseModel): model_config = ConfigDict(extra="ignore") - path: Path + source: Annotated[FileImageSource | InlineImageSource, Field(discriminator="kind")] alias: str mime_type: str + @model_validator(mode="before") + @classmethod + def _migrate_flat_source(cls, value: Any) -> Any: + # Accept and migrate the legacy flat shape `{path|data, ...}` from older + # session transcripts. + if not isinstance(value, dict) or "source" in value: + return value + if value.get("path") is not None: + return {**value, "source": {"kind": "file", "path": value["path"]}} + if value.get("data") is not None: + return {**value, "source": {"kind": "inline", "data": value["data"]}} + return value + class LLMMessage(BaseModel): model_config = ConfigDict(extra="ignore") @@ -245,6 +305,7 @@ class LLMMessage(BaseModel): name: str | None = None tool_call_id: str | None = None message_id: str | None = None + user_display_content: UserDisplayContentMetadata | None = None @model_validator(mode="before") @classmethod @@ -273,6 +334,7 @@ class LLMMessage(BaseModel): "images": getattr(v, "images", None), "message_id": getattr(v, "message_id", None) or (str(uuid4()) if role != "tool" else None), + "user_display_content": getattr(v, "user_display_content", None), } def __add__(self, other: LLMMessage) -> LLMMessage: @@ -343,6 +405,9 @@ class LLMMessage(BaseModel): name=self.name, tool_call_id=self.tool_call_id, message_id=self.message_id, + user_display_content=self.user_display_content + if self.user_display_content is not None + else other.user_display_content, ) @@ -548,15 +613,19 @@ class MessageList(Sequence[LLMMessage]): for hook in self._reset_hooks: hook() - def update_system_prompt(self, new: str) -> None: - """Update the system prompt in place. + def update_system_prompt(self, new: str, *, notify: bool = False) -> None: + """Replace the system prompt, or insert it if none exists yet. - Called from a background thread during deferred init. A single - list-item assignment is atomic under CPython's GIL, and the - ``@requires_init`` decorator ensures no ``act()`` call reads the - prompt concurrently, so no additional lock is needed here. + Under deferred init the prompt can land after messages were already + appended, so insert at the front rather than clobber slot 0. """ - self._data[0] = LLMMessage(role=Role.system, content=new) + msg = LLMMessage(role=Role.system, content=new) + if self._data and self._data[0].role == Role.system: + self._data[0] = msg + else: + self._data.insert(0, msg) + if notify: + self._notify(msg) @contextmanager def silent(self) -> Iterator[None]: diff --git a/vibe/core/utils/text.py b/vibe/core/utils/text.py index 56f80bd..0e14566 100644 --- a/vibe/core/utils/text.py +++ b/vibe/core/utils/text.py @@ -2,11 +2,20 @@ from __future__ import annotations def snippet_start_line(content: str, snippet: str) -> int | None: + lines = snippet_start_lines(content, snippet) + return lines[0] if lines else None + + +def snippet_start_lines(content: str, snippet: str) -> list[int]: if not snippet.strip("\n"): - return None - if (pos := content.find(snippet)) == -1: - return None + return [] # Skip leading newlines so the reported line is the first content line, # aligning the gutter with the diff (which renders the snippet stripped). leading = len(snippet) - len(snippet.lstrip("\n")) - return content.count("\n", 0, pos + leading) + 1 + lines: list[int] = [] + pos = content.find(snippet) + while pos != -1: + lines.append(content.count("\n", 0, pos + leading) + 1) + # Advance past the match (non-overlapping, mirroring str.replace). + pos = content.find(snippet, pos + len(snippet)) + return lines diff --git a/vibe/setup/auth/api_key_persistence.py b/vibe/setup/auth/api_key_persistence.py index 973022f..74f6efe 100644 --- a/vibe/setup/auth/api_key_persistence.py +++ b/vibe/setup/auth/api_key_persistence.py @@ -3,9 +3,14 @@ from __future__ import annotations import os from dotenv import set_key, unset_key +import keyring +from keyring.errors import KeyringError, NoKeyringError, PasswordDeleteError from vibe.core.config import DEFAULT_PROVIDERS, ProviderConfig, VibeConfig +from vibe.core.logger import logger from vibe.core.paths import GLOBAL_ENV_FILE + +_KEYRING_SERVICE = "vibe" from vibe.core.telemetry.send import TelemetryClient from vibe.core.telemetry.types import EntrypointMetadata from vibe.core.types import Backend @@ -55,9 +60,20 @@ def persist_api_key( except ValueError: return f"env_var_error:{env_key}" try: - _save_api_key_to_env_file(env_key, api_key) - except (OSError, ValueError) as err: - return f"save_error:{err}" + keyring.set_password(_KEYRING_SERVICE, env_key, api_key) + except KeyringError: + try: + _save_api_key_to_env_file(env_key, api_key) + except (OSError, ValueError) as err: + return f"save_error:{err}" + else: + # The key is safely stored in the keyring; drop any stale plaintext copy. + try: + _remove_api_key_from_env_file(env_key) + except (OSError, ValueError) as err: + logger.error( + "Failed to remove stale plaintext API key from env file", exc_info=err + ) if provider.backend == Backend.MISTRAL: try: telemetry = TelemetryClient( @@ -74,5 +90,20 @@ def remove_api_key(provider: ProviderConfig) -> None: env_key = provider.api_key_env_var if not env_key: raise ValueError("Cannot remove API key without an environment variable name") + keyring_error: KeyringError | None = None + + try: + keyring.delete_password(_KEYRING_SERVICE, env_key) + except (NoKeyringError, PasswordDeleteError): + # No keyring backend, or nothing stored to remove: both are no-ops for sign-out. + pass + except KeyringError as exc: + # Deletion was attempted but failed still clear the other copies, then + # surface the failure so sign-out does not look successful while the + # credential is still in the keyring. + keyring_error = exc + _remove_api_key_from_env_file(env_key) os.environ.pop(env_key, None) + if keyring_error is not None: + raise keyring_error diff --git a/vibe/setup/auth/auth_state.py b/vibe/setup/auth/auth_state.py index dc2f58f..3f82bf9 100644 --- a/vibe/setup/auth/auth_state.py +++ b/vibe/setup/auth/auth_state.py @@ -7,17 +7,21 @@ import os from pathlib import Path from dotenv import dotenv_values +import keyring +from keyring.errors import KeyringError from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, ProviderConfig from vibe.core.paths import GLOBAL_ENV_FILE +_KEYRING_SERVICE = "vibe" + class AuthStateKind(StrEnum): SIGNED_OUT = auto() AUTH_NOT_REQUIRED = auto() + OS_KEYRING = auto() VIBE_HOME_ENV_FILE = auto() PROCESS_ENV = auto() - VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV = auto() UNSUPPORTED_PROVIDER = auto() @@ -25,6 +29,7 @@ class AuthStateKind(StrEnum): class _AuthEnvSnapshot: env_key: str current_process_has_value: bool + keyring_has_value: bool dotenv_has_value: bool process_env_had_value_before_dotenv_load: bool @@ -79,9 +84,15 @@ def _capture_auth_env_snapshot( ) -> _AuthEnvSnapshot: resolved_env_path = env_path if env_path is not None else GLOBAL_ENV_FILE.path resolved_environ = environ if environ is not None else os.environ + try: + keyring_has_value = _has_value(keyring.get_password(_KEYRING_SERVICE, env_key)) + except KeyringError: + keyring_has_value = False + return _AuthEnvSnapshot( env_key=env_key, current_process_has_value=_has_value(resolved_environ.get(env_key)), + keyring_has_value=keyring_has_value, dotenv_has_value=_dotenv_has_value(resolved_env_path, env_key), process_env_had_value_before_dotenv_load=process_env_had_value_before_dotenv_load, ) @@ -108,6 +119,7 @@ def assess_auth_state( ) if ( not auth_snapshot.current_process_has_value + and not auth_snapshot.keyring_has_value and not auth_snapshot.dotenv_has_value ): return _auth_state( @@ -121,31 +133,31 @@ def assess_auth_state( env_key=env_key, ) - if ( - auth_snapshot.dotenv_has_value - and auth_snapshot.process_env_had_value_before_dotenv_load - ): - return _auth_state( - AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV, - can_use_active_provider=True, - sign_out_available=True, - env_key=env_key, - ) + if auth_snapshot.process_env_had_value_before_dotenv_load: + kind = AuthStateKind.PROCESS_ENV + sign_out_available = False + elif auth_snapshot.dotenv_has_value: + # load_dotenv_values injects the .env value into os.environ, and + # resolve_api_key reads os.environ before the keyring. So a .env entry is + # the active credential even when the keyring also holds one, and the + # reported state must reflect the .env file rather than the keyring. + kind = AuthStateKind.VIBE_HOME_ENV_FILE + sign_out_available = True + elif auth_snapshot.keyring_has_value: + kind = AuthStateKind.OS_KEYRING + sign_out_available = True + elif auth_snapshot.current_process_has_value: + kind = AuthStateKind.PROCESS_ENV + sign_out_available = False + else: + raise AssertionError("assess_auth_state reached unreachable state") - if auth_snapshot.dotenv_has_value: - return _auth_state( - AuthStateKind.VIBE_HOME_ENV_FILE, - can_use_active_provider=True, - sign_out_available=True, - env_key=env_key, - ) - - if auth_snapshot.current_process_has_value: - return _auth_state( - AuthStateKind.PROCESS_ENV, can_use_active_provider=True, env_key=env_key - ) - - raise AssertionError("assess_auth_state reached unreachable state") + return _auth_state( + kind, + can_use_active_provider=True, + sign_out_available=sign_out_available, + env_key=env_key, + ) __all__ = ["AuthState", "AuthStateKind", "assess_auth_state"] diff --git a/vibe/setup/update_prompt/__init__.py b/vibe/setup/update_prompt/__init__.py index c891893..0129e9f 100644 --- a/vibe/setup/update_prompt/__init__.py +++ b/vibe/setup/update_prompt/__init__.py @@ -1,8 +1,15 @@ from __future__ import annotations +from vibe.setup.update_prompt.theme import load_update_prompt_theme from vibe.setup.update_prompt.update_prompt_dialog import ( + UpdatePromptMode, UpdatePromptResult, ask_update_prompt, ) -__all__ = ["UpdatePromptResult", "ask_update_prompt"] +__all__ = [ + "UpdatePromptMode", + "UpdatePromptResult", + "ask_update_prompt", + "load_update_prompt_theme", +] diff --git a/vibe/setup/update_prompt/theme.py b/vibe/setup/update_prompt/theme.py new file mode 100644 index 0000000..69df3ad --- /dev/null +++ b/vibe/setup/update_prompt/theme.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from collections.abc import Mapping +import os +from pathlib import Path +import tomllib +from typing import Any + +from vibe.core.config import DEFAULT_THEME, resolve_theme_name +from vibe.core.config.harness_files import get_harness_files_manager +from vibe.core.logger import logger +from vibe.core.utils.io import read_safe + + +def _read_config_theme(config_file: Path) -> Any: + try: + config = tomllib.loads(read_safe(config_file, raise_on_error=True).text) + except FileNotFoundError: + return None + except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError) as exc: + logger.debug( + "Failed to read update prompt theme from %s", config_file, exc_info=exc + ) + return None + + if not isinstance(config, dict): + return None + return config.get("theme") + + +def load_update_prompt_theme( + *, environ: Mapping[str, str] | None = None, config_file: Path | None = None +) -> str: + resolved_environ = os.environ if environ is None else environ + if "VIBE_THEME" in resolved_environ: + return resolve_theme_name(resolved_environ["VIBE_THEME"]) + + resolved_config_file = config_file or get_harness_files_manager().config_file + if resolved_config_file is None: + return DEFAULT_THEME + + return resolve_theme_name(_read_config_theme(resolved_config_file)) diff --git a/vibe/setup/update_prompt/update_prompt_dialog.py b/vibe/setup/update_prompt/update_prompt_dialog.py index 73fa985..01c88fb 100644 --- a/vibe/setup/update_prompt/update_prompt_dialog.py +++ b/vibe/setup/update_prompt/update_prompt_dialog.py @@ -24,14 +24,19 @@ class UpdatePromptResult(StrEnum): QUIT = auto() +class UpdatePromptMode(StrEnum): + STARTUP = auto() + CHECK_UPGRADE = auto() + + class UpdateChoice(StrEnum): UPDATE = auto() CONTINUE = auto() -_CHOICE_LABELS: dict[UpdateChoice, str] = { - UpdateChoice.UPDATE: "Update now", - UpdateChoice.CONTINUE: "Continue with current version", +_CONTINUE_LABELS: dict[UpdatePromptMode, str] = { + UpdatePromptMode.STARTUP: "Continue with current version", + UpdatePromptMode.CHECK_UPGRADE: "Cancel upgrade", } @@ -56,11 +61,19 @@ class UpdatePromptDialog(CenterMiddle): self.succeeded = succeeded def __init__( - self, current_version: str, latest_version: str, **kwargs: Any + self, + current_version: str, + latest_version: str, + prompt_mode: UpdatePromptMode = UpdatePromptMode.STARTUP, + **kwargs: Any, ) -> None: super().__init__(**kwargs) self.current_version = current_version self.latest_version = latest_version + self._choice_labels: dict[UpdateChoice, str] = { + UpdateChoice.UPDATE: "Update now", + UpdateChoice.CONTINUE: _CONTINUE_LABELS[prompt_mode], + } self.selected: UpdateChoice = UpdateChoice.UPDATE self._option_widgets: dict[UpdateChoice, NoMarkupStatic] = {} self._is_updating = False @@ -78,7 +91,7 @@ class UpdatePromptDialog(CenterMiddle): with Horizontal(id="update-options-container"): for choice in UpdateChoice: widget = NoMarkupStatic( - f" {_CHOICE_LABELS[choice]}", classes="update-option" + f" {self._choice_labels[choice]}", classes="update-option" ) self._option_widgets[choice] = widget yield widget @@ -102,7 +115,7 @@ class UpdatePromptDialog(CenterMiddle): for choice in UpdateChoice: widget = self._option_widgets[choice] cursor = "› " if choice == self.selected else " " - widget.update(f"{cursor}{_CHOICE_LABELS[choice]}") + widget.update(f"{cursor}{self._choice_labels[choice]}") widget.remove_class("update-option--active") widget.remove_class("update-option--inactive") widget.add_class( @@ -167,12 +180,14 @@ class UpdatePromptApp(App[UpdatePromptResult]): current_version: str, latest_version: str, theme: str | None = None, + prompt_mode: UpdatePromptMode = UpdatePromptMode.STARTUP, **kwargs: Any, ) -> None: super().__init__(**kwargs) self.current_version = current_version self.latest_version = latest_version self._theme_name = theme + self._prompt_mode = prompt_mode self._dialog: UpdatePromptDialog | None = None self._update_task: asyncio.Task[None] | None = None @@ -181,7 +196,9 @@ class UpdatePromptApp(App[UpdatePromptResult]): self.theme = self._theme_name def compose(self) -> ComposeResult: - self._dialog = UpdatePromptDialog(self.current_version, self.latest_version) + self._dialog = UpdatePromptDialog( + self.current_version, self.latest_version, prompt_mode=self._prompt_mode + ) yield self._dialog async def action_quit_prompt(self) -> None: @@ -215,7 +232,12 @@ class UpdatePromptApp(App[UpdatePromptResult]): def ask_update_prompt( - current_version: str, latest_version: str, theme: str | None = None + current_version: str, + latest_version: str, + theme: str | None = None, + prompt_mode: UpdatePromptMode = UpdatePromptMode.STARTUP, ) -> UpdatePromptResult: - app = UpdatePromptApp(current_version, latest_version, theme=theme) + app = UpdatePromptApp( + current_version, latest_version, theme=theme, prompt_mode=prompt_mode + ) return app.run(inline=True) or UpdatePromptResult.CONTINUE diff --git a/vibe/whats_new.md b/vibe/whats_new.md index ef45b7a..b5f494a 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -1,4 +1,5 @@ -# What's new in v2.16.0 -- **Better slash commands**: Slash command autocomplete now uses fuzzy search -- **Readable edit diffs**: File edit previews now include syntax highlighting, line numbers, and theme-aware colors -- **Focused resume picker**: `/resume` now shows sessions for the current folder +# What's new in v2.17.0 + +- **MCP OAuth login**: Authenticate OAuth-backed MCP servers from the TUI with the new `/mcp login`, `/mcp logout`, and `/mcp status` commands +- **`--check-upgrade`**: New flag to check for a Vibe update on demand, prompt to install it, and exit +- **`--yolo`**: New shorthand alias for `--auto-approve`