From 632ea8c032cd2ea887fc4f64eef7b5d88ba2b0c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Drouin?= Date: Tue, 28 Apr 2026 17:44:07 +0200 Subject: [PATCH] v2.9.0 (#641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Antoine <33425718+anth2o@users.noreply.github.com> Co-authored-by: Bastien Co-authored-by: Clément Sirieix Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Mathias Gesbert Co-authored-by: Maxime Dolores Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com> Co-authored-by: Pierre Rossinès Co-authored-by: Quentin Co-authored-by: Robin Gullo Co-authored-by: Mistral Vibe --- AGENTS.md | 244 +-- CHANGELOG.md | 47 +- distribution/zed/extension.toml | 14 +- pyproject.toml | 2 +- tests/acp/conftest.py | 6 + tests/acp/test_acp.py | 9 +- tests/acp/test_acp_hooks.py | 171 +++ tests/acp/test_close_session.py | 101 ++ tests/acp/test_commands.py | 66 + tests/acp/test_fork_session.py | 192 +++ tests/acp/test_initialize.py | 18 +- tests/acp/test_load_session.py | 36 +- tests/acp/test_new_session.py | 9 +- tests/acp/test_session.py | 155 ++ tests/acp/test_set_config_option.py | 94 +- tests/acp/test_set_mode.py | 22 +- tests/acp/test_telemetry_notification.py | 53 + tests/acp/test_usage_update.py | 3 + .../test_slash_command_controller.py | 2 +- .../test_ui_chat_autocompletion.py | 75 +- tests/backend/data/openai_responses.py | 520 +++++++ tests/backend/test_anthropic_adapter.py | 35 +- .../backend/test_openai_responses_adapter.py | 1322 +++++++++++++++++ tests/banner/test_banner_initial_state.py | 117 +- .../cli/plan_offer/test_decide_plan_offer.py | 50 + tests/cli/test_cache.py | 73 + tests/cli/test_clipboard.py | 53 +- tests/cli/test_commands.py | 36 +- tests/cli/test_copy_command.py | 53 + tests/cli/test_copy_shortcuts.py | 20 +- tests/cli/test_feedback_bar.py | 16 +- tests/cli/test_feedback_bar_manager.py | 149 ++ tests/cli/test_mcp_app.py | 16 +- tests/cli/test_programmatic_setup.py | 194 +++ tests/cli/test_stderr_guard.py | 73 + tests/cli/test_ui_config_and_model_picker.py | 163 +- tests/cli/test_ui_teleport_availability.py | 188 +++ tests/cli/textual_ui/test_completion_popup.py | 12 + .../textual_ui/test_event_handler_hooks.py | 81 + .../cli/textual_ui/test_quit_confirmation.py | 169 +++ tests/conftest.py | 29 +- tests/core/nuage/test_remote_events_source.py | 18 + tests/core/nuage/test_workflows_client.py | 44 +- tests/core/session/test_session_id.py | 79 + tests/core/test_config_layer.py | 493 ++++++ tests/core/test_config_resolution.py | 116 +- tests/core/test_hooks.py | 504 +++++++ tests/core/test_remote_agent_loop.py | 170 +++ tests/core/test_scratchpad.py | 81 + tests/core/test_telemetry_send.py | 188 ++- tests/core/test_teleport_nuage.py | 4 +- tests/core/test_teleport_service.py | 71 +- tests/e2e/common.py | 41 +- tests/e2e/test_cli_tui_hooks.py | 87 ++ tests/e2e/test_cli_tui_session_exit.py | 5 +- tests/e2e/test_cli_tui_streaming.py | 3 +- tests/e2e/test_cli_tui_tool_approval.py | 3 +- tests/onboarding/test_ui_onboarding.py | 39 + tests/session/test_resume_sessions.py | 162 +- tests/session/test_session_loader.py | 92 +- ...t_snapshot_ask_user_question_collapsed.svg | 2 +- ...st_snapshot_ask_user_question_expanded.svg | 2 +- ...test_snapshot_shows_basic_conversation.svg | 2 +- ...izontal_scrolling_for_long_code_blocks.svg | 2 +- .../test_snapshot_config_escape_closes.svg | 2 +- .../test_snapshot_config_initial.svg | 24 +- .../test_snapshot_config_navigate_down.svg | 26 +- .../test_snapshot_config_toggle_autocopy.svg | 22 +- .../test_snapshot_data_retention.svg | 2 +- .../test_snapshot_debug_console_close.svg | 2 +- ...est_snapshot_debug_console_live_append.svg | 2 +- .../test_snapshot_debug_console_open.svg | 2 +- ...ssistant_removed_when_reasoning_starts.svg | 2 +- .../test_snapshot_feedback_bar_visible.svg | 2 +- ...shot_mcp_backspace_returns_to_overview.svg | 172 +-- .../test_snapshot_mcp_broken_server.svg | 172 +-- ...napshot_mcp_connector_back_to_overview.svg | 174 +-- .../test_snapshot_mcp_connectors_only.svg | 176 +-- ...apshot_mcp_connectors_sorted_by_status.svg | 4 +- ...test_snapshot_mcp_drill_into_connector.svg | 168 +-- ..._snapshot_mcp_enter_drills_into_server.svg | 168 +-- .../test_snapshot_mcp_escape_closes.svg | 164 +- .../test_snapshot_mcp_no_servers.svg | 2 +- .../test_snapshot_mcp_overview.svg | 172 +-- ...st_snapshot_mcp_overview_navigate_down.svg | 172 +-- .../test_snapshot_mcp_refresh_shortcut.svg | 172 +-- .../test_snapshot_mcp_server_arg.svg | 168 +-- ..._snapshot_mcp_with_connectors_overview.svg | 174 +-- ...t_snapshot_model_picker_escape_cancels.svg | 2 +- .../test_snapshot_model_picker_initial.svg | 2 +- ...st_snapshot_model_picker_navigate_down.svg | 2 +- ...ot_model_picker_select_different_model.svg | 2 +- ...st_snapshot_cycle_to_accept_edits_mode.svg | 2 +- ...st_snapshot_cycle_to_auto_approve_mode.svg | 2 +- .../test_snapshot_cycle_to_plan_mode.svg | 2 +- .../test_snapshot_cycle_wraps_to_default.svg | 2 +- .../test_snapshot_default_mode.svg | 2 +- ..._snapshot_narrator_idle_after_speaking.svg | 2 +- .../test_snapshot_narrator_speaking.svg | 4 +- .../test_snapshot_narrator_summarizing.svg | 4 +- ...ot_proxy_setup_cancel_discards_changes.svg | 2 +- ...pshot_proxy_setup_edit_existing_values.svg | 2 +- ...est_snapshot_proxy_setup_initial_empty.svg | 2 +- ...apshot_proxy_setup_initial_with_values.svg | 2 +- .../test_snapshot_proxy_setup_save_error.svg | 2 +- ...t_snapshot_proxy_setup_save_new_values.svg | 2 +- ...ffered_reasoning_yields_before_content.svg | 2 +- ...t_snapshot_shows_interleaved_reasoning.svg | 2 +- .../test_snapshot_shows_reasoning_content.svg | 2 +- ...pshot_shows_reasoning_content_expanded.svg | 2 +- ...shot_shows_release_update_notification.svg | 2 +- ...test_snapshot_rewind_error_shows_toast.svg | 2 +- .../test_snapshot_rewind_exit_on_escape.svg | 2 +- .../test_snapshot_rewind_navigate_down.svg | 2 +- .../test_snapshot_rewind_navigate_up.svg | 2 +- .../test_snapshot_rewind_panel_shown.svg | 2 +- ...napshot_shows_resumed_session_messages.svg | 2 +- ...session_resume_hides_injected_messages.svg | 2 +- ...napshot_streaming_code_fence_preserved.svg | 2 +- ...ort_command_hidden_for_non_pro_account.svg | 252 ++++ ...leport_command_visible_for_pro_account.svg | 252 ++++ ...test_snapshot_teleport_status_complete.svg | 4 +- ...shot_teleport_status_starting_workflow.svg | 4 +- ...est_snapshot_recording_indicator_shown.svg | 2 +- ...ording_not_started_when_voice_disabled.svg | 2 +- ...pshot_recording_stopped_after_keypress.svg | 2 +- ...t_snapshot_recording_then_conversation.svg | 2 +- ...shot_transcription_populates_text_area.svg | 2 +- .../test_snapshot_voice_disable.svg | 2 +- .../test_snapshot_voice_enable.svg | 2 +- .../test_snapshot_shows_no_plan_message.svg | 2 +- .../test_snapshot_shows_switch_message.svg | 2 +- .../test_snapshot_shows_upgrade_message.svg | 2 +- .../test_snapshot_shows_whats_new_message.svg | 2 +- tests/snapshots/conftest.py | 1 + .../snapshots/test_ui_snapshot_config_app.py | 1 + .../test_ui_snapshot_feedback_bar.py | 9 +- tests/snapshots/test_ui_snapshot_teleport.py | 70 +- tests/stubs/fake_backend.py | 7 +- tests/stubs/fake_mcp_registry.py | 7 + tests/test_agent_auto_compact.py | 114 +- tests/test_agent_backend.py | 120 +- tests/test_agent_observer_streaming.py | 64 +- tests/test_agent_tool_call.py | 2 +- tests/test_agents.py | 38 +- tests/test_cli_programmatic_preload.py | 2 +- tests/test_reasoning_content.py | 46 +- tests/test_system_prompt.py | 81 + tests/test_turn_summary.py | 71 + tests/tools/test_bash.py | 52 +- tests/tools/test_connectors.py | 102 +- tests/tools/test_granular_permissions.py | 6 + tests/tools/test_mcp.py | 140 +- tests/tools/test_mcp_sampling.py | 17 + tests/tools/test_scratchpad_permissions.py | 96 ++ ...test_filesystem_update_cache_repository.py | 168 ++- uv.lock | 4 +- vibe/__init__.py | 2 +- vibe/acp/acp_agent_loop.py | 303 ++-- vibe/acp/commands/registry.py | 3 +- vibe/acp/entrypoint.py | 10 +- vibe/acp/exceptions.py | 21 +- vibe/acp/session.py | 75 + vibe/acp/utils.py | 21 +- vibe/cli/autocompletion/slash_command.py | 6 +- vibe/cli/cache.py | 32 + vibe/cli/cli.py | 76 +- vibe/cli/clipboard.py | 49 +- vibe/cli/commands.py | 92 +- vibe/cli/entrypoint.py | 36 +- vibe/cli/narrator_manager/narrator_manager.py | 20 +- vibe/cli/plan_offer/decide_plan_offer.py | 3 + vibe/cli/stderr_guard.py | 101 ++ vibe/cli/textual_ui/app.py | 462 ++++-- vibe/cli/textual_ui/app.tcss | 114 +- vibe/cli/textual_ui/handlers/event_handler.py | 44 +- vibe/cli/textual_ui/quit_manager.py | 61 + .../remote/remote_session_manager.py | 15 +- vibe/cli/textual_ui/widgets/banner/banner.py | 44 +- .../cli/textual_ui/widgets/chat_input/body.py | 9 +- .../widgets/chat_input/completion_manager.py | 4 + .../widgets/chat_input/completion_popup.py | 49 +- .../widgets/chat_input/container.py | 52 +- .../widgets/chat_input/text_area.py | 7 +- vibe/cli/textual_ui/widgets/config_app.py | 61 +- vibe/cli/textual_ui/widgets/feedback_bar.py | 9 +- .../widgets/feedback_bar_manager.py | 49 + vibe/cli/textual_ui/widgets/loading.py | 21 +- vibe/cli/textual_ui/widgets/mcp_app.py | 364 +++-- vibe/cli/textual_ui/widgets/messages.py | 47 + .../cli/textual_ui/widgets/narrator_status.py | 4 +- vibe/cli/textual_ui/widgets/path_display.py | 3 + .../textual_ui/widgets/teleport_message.py | 2 +- .../cli/textual_ui/widgets/thinking_picker.py | 79 + vibe/cli/turn_summary/port.py | 1 + vibe/cli/turn_summary/tracker.py | 32 +- .../filesystem_update_cache_repository.py | 58 +- vibe/core/agent_loop.py | 278 +++- vibe/core/agents/models.py | 4 +- vibe/core/autocompletion/completers.py | 27 +- vibe/core/config/__init__.py | 22 + vibe/core/config/_settings.py | 137 +- .../config/harness_files/_harness_manager.py | 10 + vibe/core/config/layer.py | 284 ++++ vibe/core/hooks/__init__.py | 0 vibe/core/hooks/config.py | 100 ++ vibe/core/hooks/executor.py | 55 + vibe/core/hooks/manager.py | 140 ++ vibe/core/hooks/models.py | 98 ++ vibe/core/llm/backend/anthropic.py | 32 +- vibe/core/llm/backend/generic.py | 14 +- vibe/core/llm/backend/openai_responses.py | 641 ++++++++ vibe/core/llm/exceptions.py | 15 + vibe/core/llm/format.py | 1 + vibe/core/middleware.py | 4 +- vibe/core/nuage/client.py | 9 +- vibe/core/nuage/remote_events_source.py | 11 +- .../nuage/remote_workflow_event_translator.py | 87 +- vibe/core/paths/__init__.py | 2 + vibe/core/paths/_vibe_home.py | 1 + vibe/core/programmatic.py | 24 +- vibe/core/scratchpad.py | 56 + vibe/core/session/resume_sessions.py | 54 +- vibe/core/session/session_id.py | 24 + vibe/core/session/session_loader.py | 26 +- vibe/core/session/session_logger.py | 8 +- vibe/core/skills/builtins/vibe.py | 123 +- vibe/core/system_prompt.py | 35 +- vibe/core/telemetry/build_metadata.py | 65 + vibe/core/telemetry/send.py | 74 +- vibe/core/telemetry/types.py | 38 + vibe/core/teleport/nuage.py | 8 +- vibe/core/teleport/teleport.py | 54 +- vibe/core/tools/base.py | 9 +- vibe/core/tools/builtins/bash.py | 253 ++-- vibe/core/tools/builtins/grep.py | 4 +- vibe/core/tools/builtins/read_file.py | 7 +- vibe/core/tools/builtins/search_replace.py | 7 +- vibe/core/tools/builtins/task.py | 10 +- vibe/core/tools/builtins/write_file.py | 9 +- .../tools/connectors/connector_registry.py | 2 +- vibe/core/tools/manager.py | 63 +- vibe/core/tools/mcp_sampling.py | 13 +- vibe/core/tools/mcp_settings.py | 75 + vibe/core/tools/utils.py | 6 +- vibe/core/trusted_folders.py | 15 +- vibe/core/types.py | 33 +- vibe/core/utils/__init__.py | 2 + vibe/core/utils/async_subprocess.py | 65 + vibe/setup/onboarding/__init__.py | 18 +- vibe/setup/onboarding/context.py | 2 +- vibe/setup/onboarding/screens/api_key.py | 27 +- vibe/whats_new.md | 10 +- 253 files changed, 13965 insertions(+), 2525 deletions(-) create mode 100644 tests/acp/test_acp_hooks.py create mode 100644 tests/acp/test_close_session.py create mode 100644 tests/acp/test_fork_session.py create mode 100644 tests/acp/test_session.py create mode 100644 tests/acp/test_telemetry_notification.py create mode 100644 tests/backend/data/openai_responses.py create mode 100644 tests/backend/test_openai_responses_adapter.py create mode 100644 tests/cli/test_cache.py create mode 100644 tests/cli/test_copy_command.py create mode 100644 tests/cli/test_feedback_bar_manager.py create mode 100644 tests/cli/test_programmatic_setup.py create mode 100644 tests/cli/test_stderr_guard.py create mode 100644 tests/cli/test_ui_teleport_availability.py create mode 100644 tests/cli/textual_ui/test_completion_popup.py create mode 100644 tests/cli/textual_ui/test_event_handler_hooks.py create mode 100644 tests/cli/textual_ui/test_quit_confirmation.py create mode 100644 tests/core/session/test_session_id.py create mode 100644 tests/core/test_config_layer.py create mode 100644 tests/core/test_hooks.py create mode 100644 tests/core/test_scratchpad.py create mode 100644 tests/e2e/test_cli_tui_hooks.py create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_hidden_for_non_pro_account.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg create mode 100644 tests/tools/test_scratchpad_permissions.py create mode 100644 vibe/acp/session.py create mode 100644 vibe/cli/cache.py create mode 100644 vibe/cli/stderr_guard.py create mode 100644 vibe/cli/textual_ui/quit_manager.py create mode 100644 vibe/cli/textual_ui/widgets/feedback_bar_manager.py create mode 100644 vibe/cli/textual_ui/widgets/thinking_picker.py create mode 100644 vibe/core/config/layer.py create mode 100644 vibe/core/hooks/__init__.py create mode 100644 vibe/core/hooks/config.py create mode 100644 vibe/core/hooks/executor.py create mode 100644 vibe/core/hooks/manager.py create mode 100644 vibe/core/hooks/models.py create mode 100644 vibe/core/llm/backend/openai_responses.py create mode 100644 vibe/core/scratchpad.py create mode 100644 vibe/core/session/session_id.py create mode 100644 vibe/core/telemetry/build_metadata.py create mode 100644 vibe/core/telemetry/types.py create mode 100644 vibe/core/tools/mcp_settings.py create mode 100644 vibe/core/utils/async_subprocess.py diff --git a/AGENTS.md b/AGENTS.md index 3e73f88..8220f78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,181 +1,101 @@ -# python312.rule -# Rule for enforcing modern Python 3.12+ best practices. -# Applies to all Python files (*.py) in the project. -# -# Guidelines covered: -# - Use match-case syntax instead of if/elif/else for pattern matching. -# - Use the walrus operator (:=) when it simplifies assignments and tests. -# - Favor a "never nester" approach by avoiding deep nesting with early returns or guard clauses. -# - Employ modern type hints using built-in generics (list, dict) and the union pipe (|) operator, -# rather than deprecated types from the typing module (e.g., Optional, Union, Dict, List). -# - Ensure code adheres to strong static typing practices compatible with static analyzers like pyright. -# - Favor pathlib.Path methods for file system operations over older os.path functions. -# - Write code in a declarative and minimalist style that clearly expresses its intent. -# - Additional best practices including f-string formatting, comprehensions, context managers, and overall PEP 8 compliance. +# AGENTS.md -description: "Modern Python 3.12+ best practices and style guidelines for coding." -files: "**/*.py" +Conventions for AI agents and humans contributing to **Mistral Vibe** — a Python 3.12+ CLI coding assistant managed with `uv`. -guidelines: - - title: "Match-Case Syntax" - description: > - Prefer using the match-case construct over traditional if/elif/else chains when pattern matching - is applicable. This leads to clearer, more concise, and more maintainable code. +Layout: `vibe/core` is the engine (agent loop, tools, LLM backends, config); `vibe/cli` is the Textual TUI; `vibe/acp` bridges to the Agent Client Protocol; `vibe/setup` runs first-run wizards. Tests live in `tests/` with autouse fixtures in `conftest.py` and test doubles in `tests/stubs/`. - - title: "Walrus Operator" - description: > - Utilize the walrus operator (:=) to streamline code where assignment and conditional testing can be combined. - Use it judiciously when it improves readability and reduces redundancy. +## Commands - - title: "Never Nester" - description: > - Aim to keep code flat by avoiding deep nesting. Use early returns, guard clauses, and refactoring to - minimize nested structures, making your code more readable and maintainable. +Always go through `uv` — never invoke bare `python` or `pip`. - - title: "Modern Type Hints" - description: > - Adopt modern type hinting by using built-in generics like list and dict, along with the pipe (|) operator - for union types (e.g., int | None). Avoid older, deprecated constructs such as Optional, Union, Dict, and List - from the typing module. +- `uv run vibe` / `uv run vibe-acp` — the two entry points. +- `uv run pytest` — full suite (parallel via `pytest-xdist`). +- `uv run pyright` — strict type check. +- `uv run ruff check --fix .` and `uv run ruff format .` — run both after every code change and report the files modified. +- `uv run pre-commit run --all-files` — full lint pass. Install once with `uv tool install pre-commit && uv run pre-commit install`. +- Useful uv basics: `uv sync --all-extras`, `uv add `, `uv remove `. - - title: "Strong Static Typing" - description: > - Write code with explicit and robust type annotations that are fully compatible with static type checkers - like pyright. This ensures higher code reliability and easier maintenance. +## Project layout & module conventions - - title: "Pydantic-First Parsing" - description: > - Prefer Pydantic v2's native validation over ad-hoc parsing. Use `model_validate`, - `field_validator`, `from_attributes`, and field aliases to coerce external SDK/DTO objects. - Avoid manual `getattr`/`hasattr` flows and custom constructors like `from_sdk` unless they are - thin wrappers over `model_validate`. Keep normalization logic inside model validators so call sites - remain declarative and typed. +- `__init__.py` exposes the public API via an explicit `__all__`. +- Private modules are prefixed with `_` (e.g. `_settings.py`, `_config.py`). +- Pydantic models live in `models.py`; configuration in `_settings.py` / `_config.py`. +- Abstract interfaces use the `_port.py` suffix (hexagonal-style ports). +- Tests mirror the source layout; test doubles in `tests/stubs/` are named `Fake*`. - - title: "Pathlib for File Operations" - description: > - Favor the use of pathlib.Path methods for file system operations. This approach offers a more - readable, object-oriented way to handle file paths and enhances cross-platform compatibility, - reducing reliance on legacy os.path functions. +## Python style - - title: "Declarative and Minimalist Code" - description: > - Write code that is declarative—clearly expressing its intentions rather than focusing on implementation details. - Strive to keep your code minimalist by removing unnecessary complexity and boilerplate. This approach improves - readability, maintainability, and aligns with modern Python practices. +- Prefer `match` / `case` over long `if` / `elif` chains. +- Use the walrus operator `:=` only when it shortens code and improves clarity. +- Be a never-nester: early returns and guard clauses over nested blocks. +- Modern type hints only: built-in generics (`list`, `dict`) and `|` unions. Never import `Optional`, `Union`, `Dict`, `List` from `typing`. +- Use `pathlib.Path` (and `anyio.Path` in async paths) instead of `os.path`. +- Use f-strings, comprehensions, and context managers; follow PEP 8. +- Enums: `StrEnum` / `IntEnum` with `auto()` and UPPERCASE members. For type-mixing, the mix-in type comes before `Enum` in the bases. Add methods or `@property` rather than parallel lookup tables. +- Write declarative, minimalist code: express intent, drop boilerplate. +- Never call a private method from outside of it's class - - title: "Additional Best Practices" - description: > - Embrace other modern Python idioms such as: - - Using f-strings for string formatting. - - Favoring comprehensions for building lists and dictionaries. - - Employing context managers (with statements) for resource management. - - Following PEP 8 guidelines to maintain overall code style consistency. +## Typing & imports - - title: "Exception Documentation" - description: > - Document exceptions accurately and minimally in docstrings: - - Only document exceptions that are explicitly raised in the function implementation - - Remove Raises entries for exceptions that are not directly raised - - Include all possible exceptions from explicit raise statements - - For public APIs, document exceptions from called functions if they are allowed to propagate - - Avoid documenting built-in exceptions that are obvious (like TypeError from type hints) - This ensures documentation stays accurate and maintainable, avoiding the common pitfall - of listing every possible exception that could theoretically occur. +- Pyright is strict and gates CI; fix types at the source. +- No relative imports — `ban-relative-imports = "all"`. Always `from vibe.core.x import …`. +- No inline `# type: ignore` or `# noqa`. Fix with refined signatures (TypeVar, Protocol), `isinstance` guards, `typing.cast` when control flow guarantees the type, or a small typed wrapper at the boundary. - - title: "Modern Enum Usage" - description: > - Leverage Python's enum module effectively following modern practices: - - Use StrEnum for string-based enums that need string comparison - - Use IntEnum/IntFlag for performance-critical integer-based enums - - Use auto() for automatic value assignment to maintain clean code - - Always use UPPERCASE for enum members to avoid name clashes - - Add methods to enums when behavior needs to be associated with values - - Use @property for computed attributes rather than storing values - - For type mixing, ensure mix-in types appear before Enum in base class sequence - - Consider Flag/IntFlag for bit field operations - - Use _generate_next_value_ for custom value generation - - Implement __bool__ when enum boolean evaluation should depend on value - This promotes type-safe constants, self-documenting code, and maintainable value sets. +## Pydantic - - title: "No Inline Ignores" - description: > - Do not use inline suppressions like `# type: ignore[...]` or `# noqa[...]` in production code. - Instead, fix types and lint warnings at the source by: - - Refining signatures with generics (TypeVar), Protocols, or precise return types - - Guarding with `isinstance` checks before attribute access - - Using `typing.cast` when control flow guarantees the type - - Extracting small helpers to create clearer, typed boundaries - If a suppression is truly unavoidable at an external boundary, prefer a narrow, well-typed wrapper - over in-line ignores, and document the rationale in code comments. +- Parse external data via `model_validate`, `field_validator`, or `model_validator(mode="before")` — never ad-hoc `getattr` / `hasattr` walks or custom `from_sdk` constructors. +- Set `ConfigDict(extra=…)` explicitly. Use `validation_alias` (or field aliases) for kebab-case TOML keys. +- Discriminated unions (e.g. MCP `transport`): use sibling final classes plus a shared base/mixin, and compose with `Annotated[Union[...], Field(discriminator=...)]`. Never narrow the discriminator field in a subclass — it violates LSP and pyright will reject it. +- Document `Raises:` only for exceptions the function actually raises (or that propagate from public API calls). Don't list speculative built-ins. - - title: "Pydantic Discriminated Unions" - description: > - When modeling variants with a discriminated union (e.g., on a `transport` field), do not narrow a - field type in a subclass (e.g., overriding `transport: Literal['http']` with `Literal['streamable-http']`). - This violates Liskov substitution and triggers type checker errors due to invariance of class attributes. - Prefer sibling classes plus a shared mixin for common fields and helpers, and compose the union with - `Annotated[Union[...], Field(discriminator='transport')]`. - Example pattern: - - Create a base with shared non-discriminator fields (e.g., `_MCPBase`). - - Create a mixin with protocol-specific fields/methods (e.g., `_MCPHttpFields`), without a `transport`. - - Define sibling final classes per variant (e.g., `MCPHttp`, `MCPStreamableHttp`, `MCPStdio`) that set - `transport: Literal[...]` once in each final class. - - Use `match` on the discriminator to narrow types at call sites. +## Async - - title: "Use uv for All Commands" - description: > - We use uv to manage our python environment. You should never try to run bare python commands. - Always run commands using `uv` instead of invoking `python` or `pip` directly. - For example, use `uv add package` and `uv run script.py` rather than `pip install package` or `python script.py`. - This practice helps avoid environment drift and leverages modern Python packaging best practices. - Useful uv commands are: - - uv add/remove to manage dependencies - - uv sync to install dependencies declared in pyproject.toml and uv.lock - - uv run script.py to run a script within the uv environment - - uv run pytest (or any other python tool) to run the tool within the uv environment +- `asyncio` is the orchestration runtime in the agent loop and tool execution. Use `asyncio.create_task` + queues for concurrent work, not blanket `gather`. +- Use `anyio.Path` for file I/O on async paths. +- Streaming surfaces return `AsyncGenerator[Event, None]`, not coroutines. +- HTTP via `httpx.AsyncClient`; mock with `respx` in tests. - - title: "Safe File Reading" - description: > - When reading files from disk, prefer the helpers in `vibe.core.utils.io` over raw - `Path.read_text()`, `Path.read_bytes().decode()`, or `open()` calls: - - `read_safe(path)` — synchronous read with automatic encoding detection. - - `read_safe_async(path)` — async equivalent (anyio-based). - - `decode_safe(raw)` — decode an already-read `bytes` object. - These functions try UTF-8 first, then BOM detection, the locale encoding, and - `charset_normalizer` (lazily, only when cheaper candidates fail). They return a - `ReadSafeResult(text, encoding)` so callers always get valid `str` output without - having to handle encoding errors manually. - Use `raise_on_error=True` only when the caller must distinguish corrupt files from - valid ones; the default (`False`) replaces undecodable bytes with U+FFFD. +## Tools - - title: "Imports in Cursor (no Pylance)" - description: > - Cursor's built-in Pyright does not offer the "Add import" quick fix (Ctrl+.). To add a missing import: - - Use the workspace snippets: type the prefix (e.g. acpschema, acphelpers, vibetypes, vibeconfig) and accept the suggestion to insert the import line, then change the symbol name. - - Or ask Cursor: select the undefined symbol, then Cmd+K and request "Add the missing import for ". - - Or copy the import from an existing file in the repo (e.g. acp.schema, acp.helpers, vibe.core.*). +- Subclass `BaseTool` from `vibe/core/tools/base.py` with a Pydantic args model and a `BaseToolConfig` generic parameter. +- Implement `async def run(args, ctx: InvokeContext)` and yield events progressively. +- Raise `ToolError` for user-facing failures; raise `ToolPermissionError` for authorization failures. +- Declare permission with `ToolPermission` (`ALWAYS` / `ASK` / `NEVER`); honor it consistently. - - title: "Keep Builtin Vibe Skill Up-to-Date" - description: > - The file `vibe/core/skills/builtins/vibe.py` is the builtin self-awareness skill. - It documents the CLI's features for the model: config.toml fields, CLI parameters, slash - commands, agents, skills, tools, VIBE_HOME structure, and environment variables. - When you change any of the following, update `vibe/core/skills/builtins/vibe.py` - to reflect the new behavior: - - CLI arguments or flags (vibe/cli/entrypoint.py) - - config.toml fields or defaults (vibe/core/config/_settings.py) - - Slash commands (vibe/cli/commands.py) - - Built-in agents (vibe/core/agents/) - - VIBE_HOME directory layout or paths (vibe/core/paths/) - - Skill, tool, or agent discovery logic - - Environment variables - If in doubt, read the skill file and check whether your change makes any section stale. +## Logging & errors - - title: "No Docstrings in Tests" - description: > - Do not add docstrings to test functions, test methods, or test classes. - Test names should be descriptive enough to convey intent (e.g., - `test_create_user_returns_403_when_unauthorized`). Docstrings in tests add - noise, duplicate the function name, and can suppress pytest's default output - (pytest displays the docstring instead of the node id when one is present). - Use inline comments sparingly for non-obvious setup or assertions instead. +- Use `from vibe.core.logger import logger` — stdlib `logging` with `StructuredLogFormatter`, not `structlog`. +- Configure via env: `LOG_LEVEL` (default `WARNING`), `DEBUG_MODE`, `LOG_MAX_BYTES`. Logs land in `~/.vibe/logs/vibe.log`. +- Pass variables as keyword args, not interpolated into the message: prefer `logger.error("Failed to fetch", url=url)` over `logger.error(f"Failed to fetch {url}")`. +- Define module-local exception hierarchies. Always chain with `raise NewError(...) from e`. Rich exceptions expose a `_fmt()` helper for human-readable output. + +## File I/O + +- Prefer `vibe.core.utils.io.read_safe` / `read_safe_async` / `decode_safe` over raw `Path.read_text()`, `Path.read_bytes().decode()`, or `open()`. +- They return `ReadSafeResult(text, encoding)` and try UTF-8, then BOM detection, then locale, then `charset_normalizer` lazily. +- Pass `raise_on_error=True` only when callers must distinguish corrupt files from valid ones; the default replaces undecodable bytes with U+FFFD. + +## Tests + +- Stack: `pytest` + `pytest-asyncio` + `pytest-textual-snapshot` + `respx`. +- Mark async tests with `@pytest.mark.asyncio`. Mock outbound HTTP with `respx`. +- Rely on the autouse fixtures in `tests/conftest.py` (`config_dir`, `tmp_working_directory`) for filesystem and home-dir isolation. +- No docstrings on test functions, methods, or classes — descriptive names like `test_create_user_returns_403_when_unauthorized` carry the intent. Pytest displays docstrings instead of node IDs when present, which hurts. +- Tests are exempt from the `ANN` and `PLR` ruff rules (see `per-file-ignores`). + +## Git + +- Never use `git commit --amend`, `git push --force`, or `git push --force-with-lease`. +- Always create new commits and push with a plain `git push`. +- If a push is rejected due to upstream changes, rebase onto the updated remote branch — never merge and never force-push. + +## Editor tip + +In Cursor / Pyright, the "Add import" quick fix is missing — use the workspace snippets `acpschema`, `acphelpers`, `vibetypes`, `vibeconfig` to insert the import line, then rename the symbol. + + +## Autoimprovement + +- Suggest to add new rules to AGENTS.md based on user input or PR comments, when a change request could be generalized as a rule. +- Suggest updates to the README.md file according to feature changes or additions +- Keep the builtin Vibe Skill (`vibe/core/skills/builtins/vibe.py`) up-to-date. It documents the CLI's features, such as args, flags, config options and persistence, commands, built-in agents, file discovery logic. diff --git a/CHANGELOG.md b/CHANGELOG.md index df08996..2eaf153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,51 @@ 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.9.0] - 2026-04-28 + +### Added + +- Scratchpad directory for temporary working files shared with subagents +- `/copy` slash command +- Experimental hooks system with post-agent-turn lifecycle +- OpenAI Responses API adapter +- ACP session fork and session close support +- Thinking level picker in ACP CLI +- `--trust` session-only flag and fail-fast behavior in `-p` mode +- Opus 4.7 model support +- `ConfigLayer` for layered configuration resolution +- `~/.vibe/prompts` overrides for builtin prompts +- Enable/disable MCP servers and individual tools from `/mcp` menu +- Custom compaction instructions via `/compact` +- `vibe.ready` telemetry event +- Usage updates sent after every LLM turn for ACP +- Headless section in system prompt to prevent bad model behavior + +### Changed + +- Renamed `auto_approve` config to `bypass_tool_permissions` +- Increased feedback bar frequency with cooldown and TOML cache +- Feedback bar only shown when active model is Mistral +- Centralized telemetry metadata construction and wired through entrypoints +- Preserved stable session identity across compact/fork/rewind +- Filtered remote sessions by current user and deduped continue-as-new +- `--continue` now only looks for sessions of the current working directory +- Batched widget mounts and narrowed CSS selectors for UI performance + +### Fixed + +- Autocomplete popup height calculation for wrapped lines +- Autocomplete popup dismissed on tab completion and escape +- Double Ctrl+C/Ctrl+D required to quit instead of killing session immediately +- Context window overrun now shows a friendly error message +- `MallocStackLogging` error messages suppressed in CLI input +- `index.lock` leftover on interrupted deferred init +- Safe `find` commands allowed by default +- Session ID preserved when resuming sessions through ACP +- Usage updates sent after tool results instead of tool streams in ACP +- KV cache warming via x-affinity in count tokens + + ## [2.8.1] - 2026-04-21 ### Fixed @@ -457,7 +502,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bumped agent-client-protocol to 0.7.1 - Refactored UI to require AgentLoop at VibeApp construction - Updated README with new MCP server config -- Improved readability of the AskUserQuerstion tool output +- Improved readability of the AskUserQuestion tool output ### Fixed diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index 02d835c..f8478b6 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.8.1" +version = "2.9.0" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,25 +11,25 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.8.1/vibe-acp-darwin-aarch64-2.8.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.0/vibe-acp-darwin-aarch64-2.9.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.8.1/vibe-acp-darwin-x86_64-2.8.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.0/vibe-acp-darwin-x86_64-2.9.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.8.1/vibe-acp-linux-aarch64-2.8.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.0/vibe-acp-linux-aarch64-2.9.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.8.1/vibe-acp-linux-x86_64-2.8.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.0/vibe-acp-linux-x86_64-2.9.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.8.1/vibe-acp-windows-aarch64-2.8.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.0/vibe-acp-windows-aarch64-2.9.0.zip" cmd = "./vibe-acp.exe" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.8.1/vibe-acp-windows-x86_64-2.8.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.0/vibe-acp-windows-x86_64-2.9.0.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index 8fc1047..35e0339 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.8.1" +version = "2.9.0" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/acp/conftest.py b/tests/acp/conftest.py index 5fd0eab..e03ad3f 100644 --- a/tests/acp/conftest.py +++ b/tests/acp/conftest.py @@ -67,6 +67,7 @@ def create_test_session(): messages: list[dict] | None = None, title: str | None = None, end_time: str | None = None, + parent_session_id: str | None = None, ) -> Path: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") session_folder = session_dir / f"session_{timestamp}_{session_id[:8]}" @@ -84,9 +85,14 @@ def create_test_session(): "session_id": session_id, "start_time": "2024-01-01T12:00:00Z", "end_time": end_time or "2024-01-01T12:05:00Z", + "git_commit": None, + "git_branch": None, + "username": "test-user", "environment": {"working_directory": cwd}, "title": title, } + if parent_session_id is not None: + metadata["parent_session_id"] = parent_session_id metadata_file = session_folder / "meta.json" with metadata_file.open("w", encoding="utf-8") as f: diff --git a/tests/acp/test_acp.py b/tests/acp/test_acp.py index 257e8c4..7c5bdf3 100644 --- a/tests/acp/test_acp.py +++ b/tests/acp/test_acp.py @@ -513,12 +513,11 @@ class TestSessionUpdates: ), ), ) - text_responses = await read_multiple_responses(process, max_count=10) + text_responses = await read_multiple_responses( + process, max_count=15, timeout_per_response=2.0 + ) assert len(text_responses) > 0 - responses = [ - UpdateJsonRpcNotification.model_validate(json.loads(r)) - for r in text_responses - ] + responses = parse_conversation(text_responses) tool_call = next( ( diff --git a/tests/acp/test_acp_hooks.py b/tests/acp/test_acp_hooks.py new file mode 100644 index 0000000..4e626b1 --- /dev/null +++ b/tests/acp/test_acp_hooks.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import tomli_w + +from tests.conftest import get_base_config +from tests.stubs.fake_backend import FakeBackend +from tests.stubs.fake_client import FakeClient +from vibe.acp.acp_agent_loop import VibeAcpAgentLoop +from vibe.core.agent_loop import AgentLoop +from vibe.core.hooks.models import HookConfigResult, HookType +from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role, SessionMetadata + + +@pytest.fixture +def backend() -> FakeBackend: + return FakeBackend( + LLMChunk( + message=LLMMessage(role=Role.assistant, content="Hi"), + usage=LLMUsage(prompt_tokens=1, completion_tokens=1), + ) + ) + + +def _write_config(config_dir: Path, *, enable_hooks: bool) -> None: + config = get_base_config() + config["enable_experimental_hooks"] = enable_hooks + with (config_dir / "config.toml").open("wb") as f: + tomli_w.dump(config, f) + + +def _create_acp_agent() -> VibeAcpAgentLoop: + agent = VibeAcpAgentLoop() + client = FakeClient() + agent.on_connect(client) + client.on_connect(agent) + return agent + + +def _spy_agent_loop(backend: FakeBackend) -> tuple[MagicMock, type[AgentLoop]]: + spy = MagicMock() + + class Patched(AgentLoop): + def __init__(self, *args, **kwargs) -> None: + spy(*args, **kwargs) + super().__init__(*args, **kwargs, backend=backend) + + return spy, Patched + + +def _hook_config_from_spy(spy: MagicMock) -> HookConfigResult | None: + hook_config_result = spy.call_args.kwargs["hook_config_result"] + if hook_config_result is None: + return None + assert isinstance(hook_config_result, HookConfigResult) + return hook_config_result + + +async def _new_session(backend: FakeBackend) -> MagicMock: + spy, patched_loop = _spy_agent_loop(backend) + acp = _create_acp_agent() + with patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=patched_loop): + await acp.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + spy.assert_called_once() + return spy + + +async def _load_session( + backend: FakeBackend, + tmp_path: Path, + *, + session_id: str = "session-id", + loaded_messages: list[LLMMessage] | None = None, +) -> MagicMock: + spy, patched_loop = _spy_agent_loop(backend) + acp = _create_acp_agent() + session_dir = tmp_path / "sessions" / session_id + session_dir.mkdir(parents=True) + disk_metadata = SessionMetadata( + session_id=session_id, + start_time="2024-01-01T12:00:00Z", + end_time="2024-01-01T12:05:00Z", + git_commit=None, + git_branch=None, + environment={"working_directory": str(Path.cwd())}, + username="test-user", + ) + (session_dir / "meta.json").write_text(disk_metadata.model_dump_json()) + loader_result = (loaded_messages or [], {"session_id": session_id}) + + with ( + patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=patched_loop), + patch( + "vibe.acp.acp_agent_loop.SessionLoader.find_session_by_id", + return_value=session_dir, + ), + patch( + "vibe.acp.acp_agent_loop.SessionLoader.load_session", + return_value=loader_result, + ), + ): + await acp.load_session( + cwd=str(Path.cwd()), session_id=session_id, mcp_servers=[] + ) + spy.assert_called_once() + return spy + + +@pytest.mark.asyncio +class TestAcpHooksLoading: + async def test_new_session_hooks_enabled_loads_valid_hook( + self, backend: FakeBackend, config_dir: Path + ) -> None: + _write_config(config_dir, enable_hooks=True) + (config_dir / "hooks.toml").write_text( + '[[hooks]]\nname = "lint"\ntype = "post_agent_turn"\ncommand = "eslint ."\n' + ) + + spy = await _new_session(backend) + + result = _hook_config_from_spy(spy) + assert result is not None + assert len(result.hooks) == 1 + assert result.hooks[0].name == "lint" + assert result.hooks[0].type == HookType.POST_AGENT_TURN + assert result.hooks[0].command == "eslint ." + assert result.hooks[0].timeout == 30.0 + assert result.issues == [] + + async def test_new_session_hooks_enabled_invalid_toml( + self, backend: FakeBackend, config_dir: Path + ) -> None: + _write_config(config_dir, enable_hooks=True) + (config_dir / "hooks.toml").write_text("{{broken toml") + + spy = await _new_session(backend) + + result = _hook_config_from_spy(spy) + assert result is not None + assert result.hooks == [] + assert len(result.issues) == 1 + assert "Failed to parse" in result.issues[0].message + + async def test_load_session_hooks_enabled_loads_valid_hook( + self, backend: FakeBackend, config_dir: Path, tmp_path: Path + ) -> None: + _write_config(config_dir, enable_hooks=True) + (config_dir / "hooks.toml").write_text( + '[[hooks]]\nname = "lint"\ntype = "post_agent_turn"\ncommand = "eslint ."\n' + ) + + spy = await _load_session( + backend, + tmp_path, + loaded_messages=[ + LLMMessage(role=Role.system, content="system"), + LLMMessage(role=Role.user, content="hello"), + ], + ) + + result = _hook_config_from_spy(spy) + assert result is not None + assert len(result.hooks) == 1 + assert result.hooks[0].name == "lint" + assert result.hooks[0].type == HookType.POST_AGENT_TURN + assert result.hooks[0].command == "eslint ." + assert result.hooks[0].timeout == 30.0 + assert result.issues == [] diff --git a/tests/acp/test_close_session.py b/tests/acp/test_close_session.py new file mode 100644 index 0000000..cabcb0f --- /dev/null +++ b/tests/acp/test_close_session.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import asyncio +from typing import Any, cast +from unittest.mock import AsyncMock + +from acp import RequestError +from acp.schema import TextContentBlock +import pytest + +from vibe.acp.acp_agent_loop import VibeAcpAgentLoop + + +class TestCloseSession: + @pytest.mark.asyncio + async def test_close_session_removes_session_and_closes_resources( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session(cwd=".", mcp_servers=[]) + session = acp_agent_loop.sessions[session_response.session_id] + + backend_close = AsyncMock() + telemetry_close = AsyncMock() + cast(Any, session.agent_loop.backend).close = backend_close + session.agent_loop.telemetry_client.aclose = telemetry_close + + response = await acp_agent_loop.close_session(session_response.session_id) + + assert response is not None + assert session_response.session_id not in acp_agent_loop.sessions + backend_close.assert_awaited_once() + telemetry_close.assert_awaited_once() + + @pytest.mark.asyncio + async def test_close_session_cancels_active_prompt( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session(cwd=".", mcp_servers=[]) + session = acp_agent_loop.sessions[session_response.session_id] + + async def wait_forever() -> None: + await asyncio.Event().wait() + + task = session.set_prompt_task(wait_forever()) + session.agent_loop.telemetry_client.aclose = AsyncMock() + + await acp_agent_loop.close_session(session_response.session_id) + + assert task.cancelled() + + @pytest.mark.asyncio + async def test_close_session_cancels_background_tasks( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session(cwd=".", mcp_servers=[]) + session = acp_agent_loop.sessions[session_response.session_id] + + background_event = asyncio.Event() + + async def background_work() -> None: + await background_event.wait() + + bg_task = session.spawn(background_work()) + assert bg_task is not None + + session.agent_loop.telemetry_client.aclose = AsyncMock() + + await acp_agent_loop.close_session(session_response.session_id) + + assert bg_task.cancelled() + + @pytest.mark.asyncio + async def test_close_session_rejects_new_background_tasks( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session(cwd=".", mcp_servers=[]) + session = acp_agent_loop.sessions[session_response.session_id] + session.agent_loop.telemetry_client.aclose = AsyncMock() + + await acp_agent_loop.close_session(session_response.session_id) + + async def noop() -> None: + pass + + assert session.spawn(noop()) is None + + @pytest.mark.asyncio + async def test_closed_session_rejects_new_prompts( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session(cwd=".", mcp_servers=[]) + session = acp_agent_loop.sessions[session_response.session_id] + session.agent_loop.telemetry_client.aclose = AsyncMock() + + await acp_agent_loop.close_session(session_response.session_id) + + with pytest.raises(RequestError, match="Session not found"): + await acp_agent_loop.prompt( + prompt=[TextContentBlock(type="text", text="Hello")], + session_id=session_response.session_id, + ) diff --git a/tests/acp/test_commands.py b/tests/acp/test_commands.py index 92f75cb..585421d 100644 --- a/tests/acp/test_commands.py +++ b/tests/acp/test_commands.py @@ -259,6 +259,72 @@ class TestAvailableCommandsWithSkills: assert "hidden-skill" not in cmd_names +class TestSlashCommandTelemetry: + @pytest.mark.asyncio + async def test_builtin_command_fires_telemetry( + self, acp_agent_loop: VibeAcpAgentLoop, telemetry_events: list[dict] + ) -> None: + session_id = await _new_session_and_clear(acp_agent_loop) + telemetry_events.clear() + + await _prompt(acp_agent_loop, session_id, "/help") + + slash_events = [ + e for e in telemetry_events if e["event_name"] == "vibe.slash_command_used" + ] + assert len(slash_events) == 1 + assert slash_events[0]["properties"]["command"] == "help" + assert slash_events[0]["properties"]["command_type"] == "builtin" + + @pytest.mark.asyncio + async def test_skill_command_fires_telemetry( + self, + acp_agent_loop_with_skills: VibeAcpAgentLoop, + skills_dir: Path, + telemetry_events: list[dict], + ) -> None: + create_skill(skills_dir, "my-skill", "Does something") + session_id = await _new_session_and_clear(acp_agent_loop_with_skills) + telemetry_events.clear() + + await _prompt(acp_agent_loop_with_skills, session_id, "/my-skill") + + slash_events = [ + e for e in telemetry_events if e["event_name"] == "vibe.slash_command_used" + ] + assert len(slash_events) == 1 + assert slash_events[0]["properties"]["command"] == "my-skill" + assert slash_events[0]["properties"]["command_type"] == "skill" + + @pytest.mark.asyncio + async def test_unknown_slash_command_does_not_fire_telemetry( + self, acp_agent_loop: VibeAcpAgentLoop, telemetry_events: list[dict] + ) -> None: + session_id = await _new_session_and_clear(acp_agent_loop) + telemetry_events.clear() + + await _prompt(acp_agent_loop, session_id, "/nonexistent") + + slash_events = [ + e for e in telemetry_events if e["event_name"] == "vibe.slash_command_used" + ] + assert slash_events == [] + + @pytest.mark.asyncio + async def test_regular_message_does_not_fire_telemetry( + self, acp_agent_loop: VibeAcpAgentLoop, telemetry_events: list[dict] + ) -> None: + session_id = await _new_session_and_clear(acp_agent_loop) + telemetry_events.clear() + + await _prompt(acp_agent_loop, session_id, "Hello world") + + slash_events = [ + e for e in telemetry_events if e["event_name"] == "vibe.slash_command_used" + ] + assert slash_events == [] + + class TestCommandCaseInsensitivity: @pytest.mark.asyncio async def test_uppercase_command(self, acp_agent_loop: VibeAcpAgentLoop) -> None: diff --git a/tests/acp/test_fork_session.py b/tests/acp/test_fork_session.py new file mode 100644 index 0000000..9a29bb9 --- /dev/null +++ b/tests/acp/test_fork_session.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +from acp.schema import TextContentBlock +import pytest + +from vibe.acp.acp_agent_loop import VibeAcpAgentLoop +from vibe.acp.exceptions import InvalidRequestError +from vibe.core.agents.models import BuiltinAgentName +from vibe.core.session.session_id import extract_suffix +from vibe.core.types import FunctionCall, LLMMessage, Role, ToolCall + + +class TestACPForkSession: + @pytest.mark.asyncio + async def test_fork_session_clones_history_and_mode( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + source_session = acp_agent_loop.sessions[session_response.session_id] + + await acp_agent_loop.set_session_mode( + session_id=source_session.id, mode_id=BuiltinAgentName.PLAN + ) + await acp_agent_loop.prompt( + prompt=[TextContentBlock(type="text", text="Say hi")], + session_id=source_session.id, + ) + + response = await acp_agent_loop.fork_session( + cwd=str(Path.cwd()), session_id=source_session.id, mcp_servers=[] + ) + + assert response.session_id != source_session.id + assert response.modes is not None + assert response.modes.current_mode_id == BuiltinAgentName.PLAN + assert response.models is not None + assert ( + response.models.current_model_id + == source_session.agent_loop.config.active_model + ) + assert response.config_options is not None + assert len(response.config_options) == 3 + + forked_session = acp_agent_loop.sessions[response.session_id] + assert forked_session.agent_loop.agent_profile.name == BuiltinAgentName.PLAN + + source_messages = [ + message.model_dump(mode="json", exclude_none=True) + for message in source_session.agent_loop.messages + if message.role != Role.system + ] + forked_messages = [ + message.model_dump(mode="json", exclude_none=True) + for message in forked_session.agent_loop.messages + if message.role != Role.system + ] + assert forked_messages == source_messages + + @pytest.mark.asyncio + async def test_fork_session_from_user_message_keeps_full_turn( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + source_session = acp_agent_loop.sessions[session_response.session_id] + source_session.agent_loop.messages.extend([ + LLMMessage(role=Role.user, content="First", message_id="user-1"), + LLMMessage( + role=Role.assistant, content="First answer", message_id="assistant-1" + ), + LLMMessage( + role=Role.assistant, + content="", + message_id="assistant-2", + tool_calls=[ + ToolCall( + id="call-1", + index=0, + function=FunctionCall( + name="read_file", arguments='{"path":"a.txt"}' + ), + ) + ], + ), + LLMMessage(role=Role.tool, content="contents", tool_call_id="call-1"), + LLMMessage(role=Role.user, content="Second", message_id="user-2"), + LLMMessage( + role=Role.assistant, content="Second answer", message_id="assistant-3" + ), + ]) + + response = await acp_agent_loop.fork_session( + cwd=str(Path.cwd()), + session_id=source_session.id, + mcp_servers=[], + messageId="user-1", + ) + + forked_session = acp_agent_loop.sessions[response.session_id] + assert [ + (message.role, message.content, message.message_id, message.tool_call_id) + for message in forked_session.agent_loop.messages + if message.role != Role.system + ] == [ + (Role.user, "First", "user-1", None), + (Role.assistant, "First answer", "assistant-1", None), + (Role.assistant, "", "assistant-2", None), + (Role.tool, "contents", None, "call-1"), + ] + + @pytest.mark.asyncio + async def test_fork_session_rejects_non_user_message_id( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + source_session = acp_agent_loop.sessions[session_response.session_id] + source_session.agent_loop.messages.extend([ + LLMMessage(role=Role.user, content="First", message_id="user-1"), + LLMMessage( + role=Role.assistant, content="First answer", message_id="assistant-1" + ), + ]) + + with pytest.raises( + InvalidRequestError, + match="Fork from message_id is only supported for user messages", + ): + await acp_agent_loop.fork_session( + cwd=str(Path.cwd()), + session_id=source_session.id, + mcp_servers=[], + messageId="assistant-1", + ) + + @pytest.mark.asyncio + async def test_fork_session_sets_parent_session_id( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + source_session = acp_agent_loop.sessions[session_response.session_id] + + response = await acp_agent_loop.fork_session( + cwd=str(Path.cwd()), session_id=source_session.id, mcp_servers=[] + ) + + forked_session = acp_agent_loop.sessions[response.session_id] + assert forked_session.agent_loop.parent_session_id == source_session.id + + @pytest.mark.asyncio + async def test_fork_session_rejects_running_session( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + source_session = acp_agent_loop.sessions[session_response.session_id] + source_session.set_prompt_task(asyncio.sleep(10)) + + with pytest.raises( + InvalidRequestError, + match="Cannot fork a session while the agent loop is running", + ): + await acp_agent_loop.fork_session( + cwd=str(Path.cwd()), session_id=source_session.id, mcp_servers=[] + ) + + await source_session.cancel_prompt() + + @pytest.mark.asyncio + async def test_fork_session_preserves_session_id_suffix( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + source_session = acp_agent_loop.sessions[session_response.session_id] + + response = await acp_agent_loop.fork_session( + cwd=str(Path.cwd()), session_id=source_session.id, mcp_servers=[] + ) + + assert extract_suffix(response.session_id) == extract_suffix(source_session.id) diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index 82ced47..b6dcb47 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -7,6 +7,8 @@ from acp.schema import ( Implementation, PromptCapabilities, SessionCapabilities, + SessionCloseCapabilities, + SessionForkCapabilities, SessionListCapabilities, ) import pytest @@ -25,10 +27,14 @@ class TestACPInitialize: prompt_capabilities=PromptCapabilities( audio=False, embedded_context=True, image=False ), - session_capabilities=SessionCapabilities(list=SessionListCapabilities()), + session_capabilities=SessionCapabilities( + close=SessionCloseCapabilities(), + list=SessionListCapabilities(), + fork=SessionForkCapabilities(), + ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.8.1" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.0" ) assert response.auth_methods == [] @@ -49,10 +55,14 @@ class TestACPInitialize: prompt_capabilities=PromptCapabilities( audio=False, embedded_context=True, image=False ), - session_capabilities=SessionCapabilities(list=SessionListCapabilities()), + session_capabilities=SessionCapabilities( + close=SessionCloseCapabilities(), + list=SessionListCapabilities(), + fork=SessionForkCapabilities(), + ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.8.1" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.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 d621c02..c79a686 100644 --- a/tests/acp/test_load_session.py +++ b/tests/acp/test_load_session.py @@ -99,7 +99,7 @@ class TestLoadSession: } assert response.config_options is not None - assert len(response.config_options) == 2 + assert len(response.config_options) == 3 assert response.config_options[0].id == "mode" assert response.config_options[0].category == "mode" assert response.config_options[0].current_value == BuiltinAgentName.DEFAULT @@ -118,6 +118,9 @@ class TestLoadSession: assert len(response.config_options[1].options) == 2 model_option_values = {opt.value for opt in response.config_options[1].options} assert model_option_values == {"devstral-latest", "devstral-small"} + assert response.config_options[2].id == "thinking" + assert response.config_options[2].category == "thinking" + assert response.config_options[2].current_value == "off" @pytest.mark.asyncio async def test_load_session_registers_session_with_original_id( @@ -136,6 +139,7 @@ class TestLoadSession: assert session_id in acp_agent.sessions assert acp_agent.sessions[session_id].id == session_id + assert acp_agent.sessions[session_id].agent_loop.session_id == session_id @pytest.mark.asyncio async def test_load_session_injects_messages_into_agent_loop( @@ -375,6 +379,36 @@ class TestLoadSession: assert agent_updates[0].update.content.text == "First response" assert agent_updates[1].update.content.text == "Second response" + @pytest.mark.asyncio + async def test_load_session_restores_agent_loop_session_identity( + 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 = "restore-id-12345678" + parent_session_id = "parent-id-87654321" + cwd = str(Path.cwd()) + session_dir = create_test_session( + temp_session_dir, session_id, cwd, parent_session_id=parent_session_id + ) + + await acp_agent.load_session(cwd=cwd, mcp_servers=[], session_id=session_id) + + agent_loop = acp_agent.sessions[session_id].agent_loop + + assert agent_loop.session_id == session_id + assert agent_loop.parent_session_id == parent_session_id + assert agent_loop.session_logger.session_id == session_id + assert agent_loop.session_logger.session_dir == session_dir + assert agent_loop.session_logger.session_metadata is not None + assert ( + agent_loop.session_logger.session_metadata.parent_session_id + == parent_session_id + ) + @pytest.mark.asyncio async def test_replay_user_message_has_message_id( self, diff --git a/tests/acp/test_new_session.py b/tests/acp/test_new_session.py index c57f09e..f874b7c 100644 --- a/tests/acp/test_new_session.py +++ b/tests/acp/test_new_session.py @@ -99,7 +99,7 @@ class TestACPNewSession: # Check config_options assert session_response.config_options is not None - assert len(session_response.config_options) == 2 + assert len(session_response.config_options) == 3 # Mode config option mode_config = session_response.config_options[0] @@ -125,6 +125,13 @@ class TestACPNewSession: model_option_values = {opt.value for opt in model_config.options} assert model_option_values == {"devstral-latest", "devstral-small"} + # Thinking config option + thinking_config = session_response.config_options[2] + assert thinking_config.id == "thinking" + assert thinking_config.category == "thinking" + assert thinking_config.current_value == "off" + assert len(thinking_config.options) == 5 + @pytest.mark.skip(reason="TODO: Fix this test") @pytest.mark.asyncio async def test_new_session_preserves_model_after_set_model( diff --git a/tests/acp/test_session.py b/tests/acp/test_session.py new file mode 100644 index 0000000..a969ad0 --- /dev/null +++ b/tests/acp/test_session.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import MagicMock + +import pytest + +from vibe.acp.session import AcpSessionLoop + + +def _make_session() -> AcpSessionLoop: + return AcpSessionLoop( + id="test-session", agent_loop=MagicMock(), command_registry=MagicMock() + ) + + +class TestSpawn: + @pytest.mark.asyncio + async def test_spawn_creates_task(self) -> None: + session = _make_session() + ran = asyncio.Event() + + async def work() -> None: + ran.set() + + task = session.spawn(work()) + + assert task is not None + await task + assert ran.is_set() + + @pytest.mark.asyncio + async def test_spawn_returns_none_after_close(self) -> None: + session = _make_session() + await session.close() + + async def noop() -> None: + pass + + assert session.spawn(noop()) is None + + @pytest.mark.asyncio + async def test_spawn_tracks_multiple_tasks(self) -> None: + session = _make_session() + gate = asyncio.Event() + + async def wait_for_gate() -> None: + await gate.wait() + + t1 = session.spawn(wait_for_gate()) + t2 = session.spawn(wait_for_gate()) + + assert t1 is not None + assert t2 is not None + assert not t1.done() + assert not t2.done() + + gate.set() + await asyncio.gather(t1, t2) + + +class TestPromptTask: + @pytest.mark.asyncio + async def test_set_prompt_task_tracks_task(self) -> None: + session = _make_session() + + async def work() -> None: + pass + + task = session.set_prompt_task(work()) + assert session.prompt_task is task + await task + assert session.prompt_task is None + + @pytest.mark.asyncio + async def test_cancel_prompt_cancels_active_task(self) -> None: + session = _make_session() + + async def hang() -> None: + await asyncio.Event().wait() + + task = session.set_prompt_task(hang()) + await session.cancel_prompt() + assert task.cancelled() + assert session.prompt_task is None + + @pytest.mark.asyncio + async def test_cancel_prompt_is_noop_without_task(self) -> None: + session = _make_session() + await session.cancel_prompt() + + @pytest.mark.asyncio + async def test_cancel_prompt_does_not_cancel_background_tasks(self) -> None: + session = _make_session() + gate = asyncio.Event() + + async def bg() -> None: + await gate.wait() + + bg_task = session.spawn(bg()) + assert bg_task is not None + + async def hang() -> None: + await asyncio.Event().wait() + + session.set_prompt_task(hang()) + await session.cancel_prompt() + + assert not bg_task.cancelled() + gate.set() + await bg_task + + +class TestClose: + @pytest.mark.asyncio + async def test_close_cancels_all_tasks(self) -> None: + session = _make_session() + + async def hang() -> None: + await asyncio.Event().wait() + + bg = session.spawn(hang()) + prompt = session.set_prompt_task(hang()) + + assert bg is not None + + await session.close() + + assert bg.cancelled() + assert prompt.cancelled() + assert session.prompt_task is None + + @pytest.mark.asyncio + async def test_close_is_idempotent(self) -> None: + session = _make_session() + await session.close() + await session.close() + + @pytest.mark.asyncio + async def test_close_waits_for_task_cleanup(self) -> None: + session = _make_session() + cleanup_ran = asyncio.Event() + + async def task_with_cleanup() -> None: + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cleanup_ran.set() + raise + + session.spawn(task_with_cleanup()) + await asyncio.sleep(0) # let the task start + await session.close() + + assert cleanup_ran.is_set() diff --git a/tests/acp/test_set_config_option.py b/tests/acp/test_set_config_option.py index 5f3866f..b4fae67 100644 --- a/tests/acp/test_set_config_option.py +++ b/tests/acp/test_set_config_option.py @@ -75,11 +75,11 @@ class TestACPSetConfigOptionMode: assert response is not None assert response.config_options is not None - assert len(response.config_options) == 2 + assert len(response.config_options) == 3 assert ( acp_session.agent_loop.agent_profile.name == BuiltinAgentName.AUTO_APPROVE ) - assert acp_session.agent_loop.auto_approve is True + assert acp_session.agent_loop.bypass_tool_permissions is True # Verify config_options reflect the new state mode_config = response.config_options[0] @@ -126,10 +126,10 @@ class TestACPSetConfigOptionMode: assert response is not None assert response.config_options is not None - assert len(response.config_options) == 2 + assert len(response.config_options) == 3 assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.CHAT assert ( - acp_session.agent_loop.auto_approve is True + acp_session.agent_loop.bypass_tool_permissions is True ) # Chat mode auto-approves read-only tools mode_config = response.config_options[0] @@ -200,7 +200,7 @@ class TestACPSetConfigOptionModel: assert response is not None assert response.config_options is not None - assert len(response.config_options) == 2 + assert len(response.config_options) == 3 assert acp_session.agent_loop.config.active_model == "devstral-small" # Verify config_options reflect the new state @@ -315,3 +315,87 @@ class TestACPSetConfigOptionInvalidConfigId: ) assert response is None + + +class TestACPSetConfigOptionThinking: + @pytest.mark.asyncio + async def test_set_config_option_thinking_success( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + session_id = session_response.session_id + acp_session = next( + (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None + ) + assert acp_session is not None + assert acp_session.agent_loop.config.get_active_model().thinking == "off" + + response = await acp_agent_loop.set_config_option( + session_id=session_id, config_id="thinking", value="high" + ) + + assert response is not None + assert response.config_options is not None + assert len(response.config_options) == 3 + assert acp_session.agent_loop.config.get_active_model().thinking == "high" + + thinking_config = response.config_options[2] + assert thinking_config.id == "thinking" + assert thinking_config.current_value == "high" + + @pytest.mark.asyncio + async def test_set_config_option_thinking_all_levels( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + session_id = session_response.session_id + acp_session = next( + (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None + ) + assert acp_session is not None + + for level in ["low", "medium", "high", "max", "off"]: + response = await acp_agent_loop.set_config_option( + session_id=session_id, config_id="thinking", value=level + ) + assert response is not None + assert acp_session.agent_loop.config.get_active_model().thinking == level + + @pytest.mark.asyncio + async def test_set_config_option_thinking_invalid_returns_none( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + session_id = session_response.session_id + acp_session = next( + (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None + ) + assert acp_session is not None + + response = await acp_agent_loop.set_config_option( + session_id=session_id, config_id="thinking", value="ultra" + ) + + assert response is None + assert acp_session.agent_loop.config.get_active_model().thinking == "off" + + @pytest.mark.asyncio + async def test_set_config_option_thinking_empty_string_returns_none( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_response = await acp_agent_loop.new_session( + cwd=str(Path.cwd()), mcp_servers=[] + ) + session_id = session_response.session_id + + response = await acp_agent_loop.set_config_option( + session_id=session_id, config_id="thinking", value="" + ) + + assert response is None diff --git a/tests/acp/test_set_mode.py b/tests/acp/test_set_mode.py index a456a70..91f9f76 100644 --- a/tests/acp/test_set_mode.py +++ b/tests/acp/test_set_mode.py @@ -28,7 +28,7 @@ class TestACPSetMode: assert response is not None assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT - assert acp_session.agent_loop.auto_approve is False + assert acp_session.agent_loop.bypass_tool_permissions is False @pytest.mark.asyncio async def test_set_mode_to_auto_approve( @@ -44,7 +44,7 @@ class TestACPSetMode: assert acp_session is not None assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT - assert acp_session.agent_loop.auto_approve is False + assert acp_session.agent_loop.bypass_tool_permissions is False response = await acp_agent_loop.set_session_mode( session_id=session_id, mode_id=BuiltinAgentName.AUTO_APPROVE @@ -54,7 +54,7 @@ class TestACPSetMode: assert ( acp_session.agent_loop.agent_profile.name == BuiltinAgentName.AUTO_APPROVE ) - assert acp_session.agent_loop.auto_approve is True + assert acp_session.agent_loop.bypass_tool_permissions is True @pytest.mark.asyncio async def test_set_mode_to_plan(self, acp_agent_loop: VibeAcpAgentLoop) -> None: @@ -76,7 +76,7 @@ class TestACPSetMode: assert response is not None assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.PLAN assert ( - acp_session.agent_loop.auto_approve is False + acp_session.agent_loop.bypass_tool_permissions is False ) # Plan mode uses per-tool allowlists, not global auto-approve @pytest.mark.asyncio @@ -103,7 +103,7 @@ class TestACPSetMode: acp_session.agent_loop.agent_profile.name == BuiltinAgentName.ACCEPT_EDITS ) assert ( - acp_session.agent_loop.auto_approve is False + acp_session.agent_loop.bypass_tool_permissions is False ) # Accept Edits mode doesn't auto-approve all @pytest.mark.asyncio @@ -126,7 +126,7 @@ class TestACPSetMode: assert response is not None assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.CHAT assert ( - acp_session.agent_loop.auto_approve is True + acp_session.agent_loop.bypass_tool_permissions is True ) # Chat mode auto-approves read-only tools @pytest.mark.asyncio @@ -143,7 +143,7 @@ class TestACPSetMode: assert acp_session is not None initial_agent = acp_session.agent_loop.agent_profile.name - initial_auto_approve = acp_session.agent_loop.auto_approve + initial_bypass = acp_session.agent_loop.bypass_tool_permissions response = await acp_agent_loop.set_session_mode( session_id=session_id, mode_id="invalid-mode" @@ -151,7 +151,7 @@ class TestACPSetMode: assert response is None assert acp_session.agent_loop.agent_profile.name == initial_agent - assert acp_session.agent_loop.auto_approve == initial_auto_approve + assert acp_session.agent_loop.bypass_tool_permissions == initial_bypass @pytest.mark.asyncio async def test_set_mode_to_same_mode( @@ -174,7 +174,7 @@ class TestACPSetMode: assert response is not None assert acp_session.agent_loop.agent_profile.name == BuiltinAgentName.DEFAULT - assert acp_session.agent_loop.auto_approve is False + assert acp_session.agent_loop.bypass_tool_permissions is False @pytest.mark.asyncio async def test_set_mode_with_empty_string( @@ -190,7 +190,7 @@ class TestACPSetMode: assert acp_session is not None initial_agent = acp_session.agent_loop.agent_profile.name - initial_auto_approve = acp_session.agent_loop.auto_approve + initial_bypass = acp_session.agent_loop.bypass_tool_permissions response = await acp_agent_loop.set_session_mode( session_id=session_id, mode_id="" @@ -198,4 +198,4 @@ class TestACPSetMode: assert response is None assert acp_session.agent_loop.agent_profile.name == initial_agent - assert acp_session.agent_loop.auto_approve == initial_auto_approve + assert acp_session.agent_loop.bypass_tool_permissions == initial_bypass diff --git a/tests/acp/test_telemetry_notification.py b/tests/acp/test_telemetry_notification.py new file mode 100644 index 0000000..9259946 --- /dev/null +++ b/tests/acp/test_telemetry_notification.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from tests.acp.conftest import _create_acp_agent +from vibe.acp.acp_agent_loop import VibeAcpAgentLoop +from vibe.acp.exceptions import InvalidRequestError +from vibe.core.agent_loop import AgentLoop + + +@pytest.fixture +def acp_agent_loop(backend) -> VibeAcpAgentLoop: + class PatchedAgentLoop(AgentLoop): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **{**kwargs, "backend": backend}) + + patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=PatchedAgentLoop).start() + return _create_acp_agent() + + +class TestTelemetryNotification: + @pytest.mark.asyncio + async def test_ignores_unknown_event_gracefully( + self, + acp_agent_loop: VibeAcpAgentLoop, + telemetry_events: list[dict[str, object]], + ) -> None: + session = await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + telemetry_events.clear() + + await acp_agent_loop.ext_notification( + "telemetry/send", + { + "event": "vibe.unsupported_event", + "session_id": session.session_id, + "properties": {"context_type": "file"}, + }, + ) + + assert telemetry_events == [] + + @pytest.mark.asyncio + async def test_raises_on_invalid_params( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + with pytest.raises(InvalidRequestError): + await acp_agent_loop.ext_notification( + "telemetry/send", + {"event": "vibe.some_event"}, # missing session_id + ) diff --git a/tests/acp/test_usage_update.py b/tests/acp/test_usage_update.py index 856f5a5..4d15331 100644 --- a/tests/acp/test_usage_update.py +++ b/tests/acp/test_usage_update.py @@ -212,6 +212,9 @@ class TestLoadSessionUsageUpdate: "session_id": session_id, "start_time": "2024-01-01T12:00:00Z", "end_time": "2024-01-01T12:05:00Z", + "git_commit": None, + "git_branch": None, + "username": "test-user", "environment": {"working_directory": cwd}, } with (session_folder / "meta.json").open("w") as f: diff --git a/tests/autocompletion/test_slash_command_controller.py b/tests/autocompletion/test_slash_command_controller.py index f12906d..8feeb53 100644 --- a/tests/autocompletion/test_slash_command_controller.py +++ b/tests/autocompletion/test_slash_command_controller.py @@ -115,9 +115,9 @@ def test_on_text_change_limits_the_number_of_results_and_preserves_insertion_ord suggestions, selected_index = view.suggestion_events[-1] assert len(suggestions) == 7 assert [suggestion.alias for suggestion in suggestions] == [ + "/help", "/config", "/compact", - "/help", "/summarize", "/logpath", "/exit", diff --git a/tests/autocompletion/test_ui_chat_autocompletion.py b/tests/autocompletion/test_ui_chat_autocompletion.py index a26e8b9..693292d 100644 --- a/tests/autocompletion/test_ui_chat_autocompletion.py +++ b/tests/autocompletion/test_ui_chat_autocompletion.py @@ -8,7 +8,10 @@ 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 +from vibe.cli.textual_ui.widgets.chat_input.completion_popup import ( + CompletionPopup, + _CompletionItem, +) from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer @@ -20,7 +23,7 @@ async def test_popup_appears_with_matching_suggestions(vibe_app: VibeApp) -> Non await pilot.press(*"/com") - popup_content = str(popup.render()) + popup_content = popup.content_text assert popup.styles.display == "block" assert "/compact" in popup_content assert "Compact conversation history by summarizing" in popup_content @@ -39,7 +42,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_keeps_popup_visible( +async def test_pressing_tab_writes_selected_command_and_hides_popup( vibe_app: VibeApp, ) -> None: async with vibe_app.run_test() as pilot: @@ -49,22 +52,22 @@ async def test_pressing_tab_writes_selected_command_and_keeps_popup_visible( await pilot.press(*"/co") await pilot.press("tab") - assert chat_input.value == "/compact" - assert popup.styles.display == "block" + assert chat_input.value == "/config" + assert popup.styles.display == "none" def ensure_selected_command(popup: CompletionPopup, expected_alias: str) -> None: - renderable = popup.render() - assert isinstance(renderable, Content) - content = str(renderable) - selected_aliases: list[str] = [] - 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) + 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 @@ -77,11 +80,11 @@ async def test_arrow_navigation_updates_selected_suggestion(vibe_app: VibeApp) - await pilot.press(*"/c") - ensure_selected_command(popup, "/clear") + ensure_selected_command(popup, "/config") await pilot.press("down") - ensure_selected_command(popup, "/compact") - await pilot.press("up") ensure_selected_command(popup, "/clear") + await pilot.press("up") + ensure_selected_command(popup, "/config") @pytest.mark.asyncio @@ -91,11 +94,11 @@ async def test_arrow_navigation_cycles_through_suggestions(vibe_app: VibeApp) -> await pilot.press(*"/co") - ensure_selected_command(popup, "/compact") - await pilot.press("down") ensure_selected_command(popup, "/config") - await pilot.press("up") + await pilot.press("down") ensure_selected_command(popup, "/compact") + await pilot.press("up") + ensure_selected_command(popup, "/config") @pytest.mark.asyncio @@ -155,7 +158,7 @@ async def test_path_completion_popup_lists_files_and_directories( await pilot.press(*"@s") - popup_content = str(popup.render()) + popup_content = popup.content_text assert "src/" in popup_content assert popup.styles.display == "block" @@ -176,7 +179,7 @@ async def test_path_completion_popup_shows_up_to_ten_results( await pilot.press(*"@src/core/extra/") - popup_content = str(popup.render()) + popup_content = popup.content_text assert "src/core/extra/extra_file_1.py" in popup_content assert "src/core/extra/extra_file_10.py" in popup_content assert "src/core/extra/extra_file_11.py" in popup_content @@ -190,6 +193,24 @@ async def test_path_completion_popup_shows_up_to_ten_results( assert popup.styles.display == "block" +@pytest.mark.asyncio +async def test_pressing_tab_on_directory_keeps_popup_visible_with_contents( + vibe_app: VibeApp, file_tree: Path +) -> None: + async with vibe_app.run_test() as pilot: + chat_input = vibe_app.query_one(ChatInputContainer) + popup = vibe_app.query_one(CompletionPopup) + + await pilot.press(*"@sr") + await pilot.press("tab") + await pilot.pause(0.2) + + assert chat_input.value == "@src/" + popup_content = popup.content_text + assert popup.styles.display == "block" + assert "src/main.py" in popup_content + + @pytest.mark.asyncio async def test_pressing_tab_writes_selected_path_name_and_hides_popup( vibe_app: VibeApp, file_tree: Path @@ -229,7 +250,7 @@ async def test_fuzzy_matches_subsequence_characters( await pilot.press(*"@src/utils/handling") - popup_content = str(popup.render()) + popup_content = popup.content_text assert "src/utils/error_handling.py" in popup_content assert popup.styles.display == "block" @@ -243,7 +264,7 @@ async def test_fuzzy_matches_word_boundaries( await pilot.press(*"@src/utils/eh") - popup_content = str(popup.render()) + popup_content = popup.content_text assert "src/utils/error_handling.py" in popup_content assert popup.styles.display == "block" @@ -257,7 +278,7 @@ async def test_finds_files_recursively_by_filename( await pilot.press(*"@entryp") - popup_content = str(popup.render()) + popup_content = popup.content_text assert "vibe/acp/entrypoint.py" in popup_content assert popup.styles.display == "block" @@ -271,7 +292,7 @@ async def test_finds_files_recursively_with_partial_path( await pilot.press(*"@acp/entry") - popup_content = str(popup.render()) + popup_content = popup.content_text assert "vibe/acp/entrypoint.py" in popup_content assert popup.styles.display == "block" diff --git a/tests/backend/data/openai_responses.py b/tests/backend/data/openai_responses.py new file mode 100644 index 0000000..2e8d580 --- /dev/null +++ b/tests/backend/data/openai_responses.py @@ -0,0 +1,520 @@ +from __future__ import annotations + +import json +from typing import Any + +from tests.backend.data import Chunk, JsonResponse, ResultData, Url + +OPENAI_RESPONSES_TEST_BASE_URL = "https://api.openai.com" + + +def _sse_event(data: dict[str, Any] | str) -> Chunk: + if data == "[DONE]": + return b"data: [DONE]" + return f"data: {json.dumps(data, separators=(',', ':'))}".encode() + + +def _usage(prompt_tokens: int = 0, completion_tokens: int = 0) -> dict[str, int]: + return {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens} + + +def _result( + message: str = "", + *, + prompt_tokens: int = 0, + completion_tokens: int = 0, + reasoning_content: str | None = None, + tool_calls: list[dict[str, Any]] | None = None, +) -> ResultData: + result: ResultData = { + "message": message, + "usage": _usage(prompt_tokens, completion_tokens), + } + if reasoning_content is not None: + result["reasoning_content"] = reasoning_content + if tool_calls is not None: + result["tool_calls"] = tool_calls + return result + + +def _tool_call_result( + name: str | None, arguments: str, index: int | None +) -> dict[str, Any]: + return {"name": name, "arguments": arguments, "index": index} + + +def _output_text(text: str) -> dict[str, Any]: + return {"type": "output_text", "text": text, "annotations": [], "logprobs": []} + + +def _message_output_item( + message_id: str, text: str, *, phase: str | None = None, status: str = "completed" +) -> dict[str, Any]: + item: dict[str, Any] = { + "id": message_id, + "type": "message", + "status": status, + "content": [_output_text(text)], + "role": "assistant", + } + if phase is not None: + item["phase"] = phase + return item + + +def _stream_message_item( + message_id: str, *, phase: str | None = None, status: str = "in_progress" +) -> dict[str, Any]: + item: dict[str, Any] = { + "id": message_id, + "type": "message", + "status": status, + "content": [], + "role": "assistant", + } + if phase is not None: + item["phase"] = phase + return item + + +def _function_call_item( + item_id: str, call_id: str, name: str, arguments: str, *, status: str = "completed" +) -> dict[str, Any]: + return { + "id": item_id, + "type": "function_call", + "call_id": call_id, + "name": name, + "arguments": arguments, + "status": status, + } + + +SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [ + ( + OPENAI_RESPONSES_TEST_BASE_URL, + { + "id": "resp_fake_id_1234", + "object": "response", + "created_at": 1234567890, + "model": "gpt-4o-2024-08-06", + "output": [ + _message_output_item( + "msg_fake_id_5678", "Hello! How can I help you today?" + ) + ], + "usage": {"input_tokens": 100, "output_tokens": 200, "total_tokens": 300}, + }, + _result( + "Hello! How can I help you today?", prompt_tokens=100, completion_tokens=200 + ), + ) +] + +TOOL_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [ + ( + OPENAI_RESPONSES_TEST_BASE_URL, + { + "id": "resp_fake_id_9012", + "object": "response", + "created_at": 1234567890, + "model": "gpt-4o-2024-08-06", + "output": [ + _message_output_item("msg_fake_id_3456", ""), + _function_call_item( + "fc_fake_id_7890", + "call_fake_id_1111", + "some_tool", + '{"some_argument": "some_argument_value"}', + ), + ], + "usage": {"input_tokens": 100, "output_tokens": 200, "total_tokens": 300}, + }, + _result( + prompt_tokens=100, + completion_tokens=200, + tool_calls=[ + _tool_call_result( + "some_tool", '{"some_argument": "some_argument_value"}', 1 + ) + ], + ), + ) +] + +STREAMED_SIMPLE_CONVERSATION_PARAMS: list[tuple[Url, list[Chunk], list[ResultData]]] = [ + ( + OPENAI_RESPONSES_TEST_BASE_URL, + [ + _sse_event({ + "type": "response.created", + "response": { + "id": "resp_fake_id_1234", + "object": "response", + "created_at": 1234567890, + "model": "gpt-4o-2024-08-06", + "output": [], + "usage": None, + }, + }), + _sse_event({ + "type": "response.in_progress", + "response": {"id": "resp_fake_id_1234"}, + }), + _sse_event({ + "type": "response.output_item.added", + "output_index": 0, + "item": _stream_message_item("msg_fake_id_5678"), + }), + _sse_event({ + "type": "response.content_part.added", + "output_index": 0, + "content_index": 0, + "part": _output_text(""), + }), + _sse_event({ + "type": "response.output_text.delta", + "output_index": 0, + "content_index": 0, + "delta": "Hello", + }), + _sse_event({ + "type": "response.output_text.delta", + "output_index": 0, + "content_index": 0, + "delta": "!", + }), + _sse_event({ + "type": "response.output_text.done", + "output_index": 0, + "content_index": 0, + "text": "Hello!", + }), + _sse_event({ + "type": "response.content_part.done", + "output_index": 0, + "content_index": 0, + "part": _output_text("Hello!"), + }), + _sse_event({ + "type": "response.output_item.done", + "output_index": 0, + "item": _message_output_item("msg_fake_id_5678", "Hello!"), + }), + _sse_event({ + "type": "response.completed", + "response": { + "id": "resp_fake_id_1234", + "object": "response", + "created_at": 1234567890, + "model": "gpt-4o-2024-08-06", + "output": [_message_output_item("msg_fake_id_5678", "Hello!")], + "usage": { + "input_tokens": 100, + "output_tokens": 200, + "total_tokens": 300, + }, + }, + }), + _sse_event("[DONE]"), + ], + [ + _result(), + _result(), + _result(), + _result(), + _result("Hello"), + _result("!"), + _result(), + _result(), + _result(), + _result(prompt_tokens=100, completion_tokens=200), + ], + ) +] + +COMMENTARY_CONVERSATION_PARAMS: list[tuple[Url, JsonResponse, ResultData]] = [ + ( + OPENAI_RESPONSES_TEST_BASE_URL, + { + "id": "resp_thinking_1234", + "object": "response", + "created_at": 1234567890, + "model": "gpt-5.4-2025-04-14", + "output": [ + _message_output_item( + "msg_commentary_5678", + "The user said hello, I should respond warmly.", + phase="commentary", + ), + _message_output_item( + "msg_final_9012", + "Hello! How can I help you today?", + phase="final_answer", + ), + ], + "usage": {"input_tokens": 150, "output_tokens": 250, "total_tokens": 400}, + }, + _result( + "Hello! How can I help you today?", + prompt_tokens=150, + completion_tokens=250, + reasoning_content="The user said hello, I should respond warmly.", + ), + ) +] + +STREAMED_COMMENTARY_CONVERSATION_PARAMS: list[ + tuple[Url, list[Chunk], list[ResultData]] +] = [ + ( + OPENAI_RESPONSES_TEST_BASE_URL, + [ + _sse_event({ + "type": "response.created", + "response": { + "id": "resp_thinking_1234", + "object": "response", + "created_at": 1234567890, + "model": "gpt-5.4-2025-04-14", + "output": [], + "usage": None, + }, + }), + _sse_event({ + "type": "response.in_progress", + "response": {"id": "resp_thinking_1234"}, + }), + _sse_event({ + "type": "response.output_item.added", + "output_index": 0, + "item": _stream_message_item("msg_commentary_5678", phase="commentary"), + }), + _sse_event({ + "type": "response.content_part.added", + "output_index": 0, + "content_index": 0, + "part": _output_text(""), + }), + _sse_event({ + "type": "response.output_text.delta", + "output_index": 0, + "content_index": 0, + "delta": "Thinking", + }), + _sse_event({ + "type": "response.output_text.delta", + "output_index": 0, + "content_index": 0, + "delta": " about it...", + }), + _sse_event({ + "type": "response.output_text.done", + "output_index": 0, + "content_index": 0, + "text": "Thinking about it...", + }), + _sse_event({ + "type": "response.content_part.done", + "output_index": 0, + "content_index": 0, + "part": _output_text("Thinking about it..."), + }), + _sse_event({ + "type": "response.output_item.done", + "output_index": 0, + "item": _message_output_item( + "msg_commentary_5678", "Thinking about it...", phase="commentary" + ), + }), + _sse_event({ + "type": "response.output_item.added", + "output_index": 1, + "item": _stream_message_item("msg_final_9012", phase="final_answer"), + }), + _sse_event({ + "type": "response.content_part.added", + "output_index": 1, + "content_index": 0, + "part": _output_text(""), + }), + _sse_event({ + "type": "response.output_text.delta", + "output_index": 1, + "content_index": 0, + "delta": "Hello", + }), + _sse_event({ + "type": "response.output_text.delta", + "output_index": 1, + "content_index": 0, + "delta": "!", + }), + _sse_event({ + "type": "response.output_text.done", + "output_index": 1, + "content_index": 0, + "text": "Hello!", + }), + _sse_event({ + "type": "response.content_part.done", + "output_index": 1, + "content_index": 0, + "part": _output_text("Hello!"), + }), + _sse_event({ + "type": "response.output_item.done", + "output_index": 1, + "item": _message_output_item( + "msg_final_9012", "Hello!", phase="final_answer" + ), + }), + _sse_event({ + "type": "response.completed", + "response": { + "id": "resp_thinking_1234", + "object": "response", + "created_at": 1234567890, + "model": "gpt-5.4-2025-04-14", + "output": [ + _message_output_item( + "msg_commentary_5678", + "Thinking about it...", + phase="commentary", + ), + _message_output_item( + "msg_final_9012", "Hello!", phase="final_answer" + ), + ], + "usage": { + "input_tokens": 150, + "output_tokens": 250, + "total_tokens": 400, + }, + }, + }), + _sse_event("[DONE]"), + ], + [ + _result(), + _result(), + _result(), + _result(), + _result(reasoning_content="Thinking"), + _result(reasoning_content=" about it..."), + _result(), + _result(), + _result(), + _result(), + _result(), + _result("Hello"), + _result("!"), + _result(), + _result(), + _result(), + _result(prompt_tokens=150, completion_tokens=250), + ], + ) +] + +STREAMED_TOOL_CONVERSATION_PARAMS: list[tuple[Url, list[Chunk], list[ResultData]]] = [ + ( + OPENAI_RESPONSES_TEST_BASE_URL, + [ + _sse_event({ + "type": "response.created", + "response": { + "id": "resp_fake_id_9012", + "object": "response", + "created_at": 1234567890, + "model": "gpt-4o-2024-08-06", + "output": [], + "usage": None, + }, + }), + _sse_event({ + "type": "response.in_progress", + "response": {"id": "resp_fake_id_9012"}, + }), + _sse_event({ + "type": "response.output_item.added", + "output_index": 0, + "item": _function_call_item( + "fc_fake_id_7890", + "call_fake_id_1111", + "some_tool", + "", + status="in_progress", + ), + }), + _sse_event({ + "type": "response.function_call_arguments.delta", + "output_index": 0, + "call_id": "call_fake_id_1111", + "delta": '{"some_argument": ', + }), + _sse_event({ + "type": "response.function_call_arguments.delta", + "output_index": 0, + "call_id": "call_fake_id_1111", + "delta": '"some_argument_value"}', + }), + _sse_event({ + "type": "response.function_call_arguments.done", + "output_index": 0, + "call_id": "call_fake_id_1111", + "name": "some_tool", + "arguments": '{"some_argument": "some_argument_value"}', + }), + _sse_event({ + "type": "response.output_item.done", + "output_index": 0, + "item": _function_call_item( + "fc_fake_id_7890", + "call_fake_id_1111", + "some_tool", + '{"some_argument": "some_argument_value"}', + ), + }), + _sse_event({ + "type": "response.completed", + "response": { + "id": "resp_fake_id_9012", + "object": "response", + "created_at": 1234567890, + "model": "gpt-4o-2024-08-06", + "output": [ + _function_call_item( + "fc_fake_id_7890", + "call_fake_id_1111", + "some_tool", + '{"some_argument": "some_argument_value"}', + ) + ], + "usage": { + "input_tokens": 100, + "output_tokens": 200, + "total_tokens": 300, + }, + }, + }), + _sse_event("[DONE]"), + ], + [ + _result(), + _result(), + _result(tool_calls=[_tool_call_result("some_tool", "", 0)]), + _result(), + _result(), + _result( + tool_calls=[ + _tool_call_result( + "some_tool", '{"some_argument": "some_argument_value"}', 0 + ) + ] + ), + _result(), + _result(prompt_tokens=100, completion_tokens=200), + ], + ) +] diff --git a/tests/backend/test_anthropic_adapter.py b/tests/backend/test_anthropic_adapter.py index 14ddf8f..c316d17 100644 --- a/tests/backend/test_anthropic_adapter.py +++ b/tests/backend/test_anthropic_adapter.py @@ -465,11 +465,14 @@ class TestAdapterPrepareRequest: assert payload["temperature"] == 1 assert payload["max_tokens"] == expected_budget + 8192 + @pytest.mark.parametrize( + "model_name", ["claude-opus-4-6-20260101", "claude-opus-4-7-20260418"] + ) @pytest.mark.parametrize("level", ["low", "medium", "high"]) - def test_thinking_levels_adaptive_model(self, adapter, provider, level): + def test_thinking_levels_adaptive_model(self, adapter, provider, model_name, level): messages = [LLMMessage(role=Role.user, content="Hello")] req = adapter.prepare_request( - model_name="claude-opus-4-6-20260101", + model_name=model_name, messages=messages, temperature=0.5, tools=None, @@ -480,11 +483,33 @@ class TestAdapterPrepareRequest: thinking=level, ) payload = json.loads(req.body) - assert payload["thinking"] == {"type": "adaptive"} + assert payload["thinking"] == {"type": "adaptive", "display": "summarized"} assert payload["output_config"] == {"effort": level} - assert payload["temperature"] == 1 + if "opus-4-7" in model_name: + assert "temperature" not in payload + else: + assert payload["temperature"] == 1 assert payload["max_tokens"] == 32_768 + @pytest.mark.parametrize("thinking_level", ["off", "low", "medium", "high"]) + def test_temperature_omitted_for_deprecated_model( + self, adapter, provider, thinking_level + ): + messages = [LLMMessage(role=Role.user, content="Hello")] + req = adapter.prepare_request( + model_name="claude-opus-4-7-20260418", + messages=messages, + temperature=0.5, + tools=None, + max_tokens=None, + tool_choice=None, + enable_streaming=False, + provider=provider, + thinking=thinking_level, + ) + payload = json.loads(req.body) + assert "temperature" not in payload + def test_history_forced_thinking_budget_model(self, adapter, provider): messages = [ LLMMessage(role=Role.user, content="Hello"), @@ -533,7 +558,7 @@ class TestAdapterPrepareRequest: provider=provider, ) payload = json.loads(req.body) - assert payload["thinking"] == {"type": "adaptive"} + assert payload["thinking"] == {"type": "adaptive", "display": "summarized"} assert payload["output_config"] == {"effort": "medium"} assert payload["max_tokens"] == 32_768 diff --git a/tests/backend/test_openai_responses_adapter.py b/tests/backend/test_openai_responses_adapter.py new file mode 100644 index 0000000..f6e197c --- /dev/null +++ b/tests/backend/test_openai_responses_adapter.py @@ -0,0 +1,1322 @@ +"""Tests for the OpenAI Responses API adapter. + +Tests cover: +- Request preparation (payload structure, message conversion, tool conversion) +- Non-streaming response parsing +- Streaming event parsing +- Integration with GenericBackend via respx mocks +""" + +from __future__ import annotations + +import json + +import httpx +from pydantic import ValidationError +import pytest +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 vibe.core.config import ModelConfig, ProviderConfig +from vibe.core.llm.backend.generic import GenericBackend +from vibe.core.llm.backend.openai_responses import OpenAIResponsesAdapter +from vibe.core.types import ( + AvailableFunction, + AvailableTool, + FunctionCall, + LLMChunk, + LLMMessage, + Role, + ToolCall, +) + + +@pytest.fixture +def adapter(): + return OpenAIResponsesAdapter() + + +@pytest.fixture +def provider(): + return _make_provider() + + +@pytest.fixture +def model(): + return _make_model() + + +def _make_provider(base_url: Url = OPENAI_RESPONSES_TEST_BASE_URL) -> ProviderConfig: + return ProviderConfig( + name="openai", + api_base=f"{base_url}/v1", + api_key_env_var="OPENAI_API_KEY", + api_style="openai-responses", + ) + + +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: + return GenericBackend(provider=_make_provider(base_url)) + + +def _prepare(adapter, provider, messages, **kwargs): + defaults = dict( + model_name="gpt-4o", + messages=messages, + temperature=0.2, + tools=None, + max_tokens=None, + tool_choice=None, + enable_streaming=False, + provider=provider, + ) + defaults.update(kwargs) + return json.loads(adapter.prepare_request(**defaults).body) + + +def _assert_chunk_matches(result: LLMChunk, expected_result: ResultData) -> None: + assert result.message.content == expected_result["message"] + assert result.message.reasoning_content == expected_result.get("reasoning_content") + assert result.usage is not None + assert result.usage.prompt_tokens == expected_result["usage"]["prompt_tokens"] + assert ( + result.usage.completion_tokens == expected_result["usage"]["completion_tokens"] + ) + + expected_tool_calls = expected_result.get("tool_calls") + if result.message.tool_calls is None: + assert expected_tool_calls is None + return + + assert expected_tool_calls is not None + assert len(result.message.tool_calls) == len(expected_tool_calls) + for tool_call, expected_tool_call in zip( + result.message.tool_calls, expected_tool_calls, strict=True + ): + assert tool_call.function.name == expected_tool_call["name"] + assert tool_call.function.arguments == expected_tool_call["arguments"] + assert tool_call.index == expected_tool_call["index"] + + +class TestPrepareRequest: + def test_endpoint(self, adapter): + assert adapter.endpoint == "/responses" + + def test_simple_message(self, adapter, provider): + payload = _prepare( + adapter, provider, [LLMMessage(role=Role.user, content="Hello")] + ) + assert payload["model"] == "gpt-4o" + assert payload["input"] == [{"role": "user", "content": "Hello"}] + assert "instructions" not in payload + assert payload["store"] is False + + def test_system_message_becomes_system_input_item(self, adapter, provider): + payload = _prepare( + adapter, + provider, + [ + LLMMessage(role=Role.system, content="You are helpful."), + LLMMessage(role=Role.user, content="Hi"), + ], + ) + assert "instructions" not in payload + assert payload["input"] == [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hi"}, + ] + + def test_consecutive_user_messages_are_merged(self, adapter, provider): + payload = _prepare( + adapter, + provider, + [ + LLMMessage(role=Role.user, content="Hi"), + LLMMessage(role=Role.user, content="Again"), + ], + ) + assert payload["input"] == [{"role": "user", "content": "Hi\n\nAgain"}] + + def test_multiple_system_messages_are_preserved(self, adapter, provider): + payload = _prepare( + adapter, + provider, + [ + LLMMessage(role=Role.system, content="Rule 1."), + LLMMessage(role=Role.system, content="Rule 2."), + LLMMessage(role=Role.user, content="Hi"), + ], + ) + assert "instructions" not in payload + assert payload["input"] == [ + {"role": "system", "content": "Rule 1."}, + {"role": "system", "content": "Rule 2."}, + {"role": "user", "content": "Hi"}, + ] + + def test_tool_message_becomes_function_call_output(self, adapter, provider): + payload = _prepare( + adapter, + provider, + [ + LLMMessage(role=Role.user, content="Hi"), + LLMMessage( + role=Role.tool, content='{"result": 42}', tool_call_id="call_123" + ), + ], + ) + tool_output = payload["input"][1] + assert tool_output["type"] == "function_call_output" + assert tool_output["call_id"] == "call_123" + assert tool_output["output"] == '{"result": 42}' + + def test_assistant_tool_calls_become_function_call_items(self, adapter, provider): + payload = _prepare( + adapter, + provider, + [ + LLMMessage(role=Role.user, content="What's the weather?"), + LLMMessage( + role=Role.assistant, + content="", + tool_calls=[ + ToolCall( + id="call_abc", + function=FunctionCall( + name="get_weather", arguments='{"location": "Paris"}' + ), + ) + ], + ), + LLMMessage( + role=Role.tool, content='{"temp": 20}', tool_call_id="call_abc" + ), + ], + ) + # input[0] = user, input[1] = assistant message, input[2] = function_call, + # input[3] = function_call_output + assert len(payload["input"]) == 4 + fc = payload["input"][2] + assert fc["type"] == "function_call" + assert fc["call_id"] == "call_abc" + assert fc["name"] == "get_weather" + assert fc["arguments"] == '{"location": "Paris"}' + fco = payload["input"][3] + assert fco["type"] == "function_call_output" + assert fco["call_id"] == "call_abc" + + def test_assistant_reasoning_state_becomes_reasoning_input_items( + self, adapter, provider + ): + payload = _prepare( + adapter, + provider, + [ + LLMMessage( + role=Role.assistant, + content="Answer", + reasoning_state=["enc:abc", "enc:def"], + ) + ], + ) + + assert payload["input"] == [ + {"type": "reasoning", "encrypted_content": "enc:abc"}, + {"type": "reasoning", "encrypted_content": "enc:def"}, + { + "role": "assistant", + "content": [{"type": "output_text", "text": "Answer"}], + }, + ] + + def test_tools_converted_to_flat_format(self, adapter, provider): + tools = [ + AvailableTool( + function=AvailableFunction( + name="get_weather", + description="Get the weather", + parameters={ + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + ) + ) + ] + payload = _prepare( + adapter, provider, [LLMMessage(role=Role.user, content="Hi")], tools=tools + ) + assert len(payload["tools"]) == 1 + tool = payload["tools"][0] + # Responses API uses flat format (no nested "function" key) + assert tool["type"] == "function" + assert tool["name"] == "get_weather" + assert tool["description"] == "Get the weather" + assert "function" not in tool + + def test_max_tokens_becomes_max_output_tokens(self, adapter, provider): + payload = _prepare( + adapter, + provider, + [LLMMessage(role=Role.user, content="Hi")], + max_tokens=100, + ) + assert payload["max_output_tokens"] == 100 + assert "max_tokens" not in payload + + def test_temperature_is_preserved_for_supported_models(self, adapter, provider): + payload = _prepare( + adapter, + provider, + [LLMMessage(role=Role.user, content="Hi")], + model_name="gpt-4o", + temperature=0.7, + ) + assert payload["temperature"] == 0.7 + + def test_temperature_is_omitted_for_reasoning_models(self, adapter, provider): + payload = _prepare( + adapter, + provider, + [LLMMessage(role=Role.user, content="Hi")], + model_name="gpt-5.4", + temperature=0.7, + ) + assert "temperature" not in payload + + def test_streaming_flag(self, adapter, provider): + payload = _prepare( + adapter, + provider, + [LLMMessage(role=Role.user, content="Hi")], + enable_streaming=True, + ) + assert payload["stream"] is True + + def test_no_stream_by_default(self, adapter, provider): + payload = _prepare( + adapter, provider, [LLMMessage(role=Role.user, content="Hi")] + ) + assert "stream" not in payload + + def test_tool_choice_string(self, adapter, provider): + tool = AvailableTool( + function=AvailableFunction( + name="search", + description="Search", + parameters={"type": "object", "properties": {}}, + ) + ) + payload = _prepare( + adapter, + provider, + [LLMMessage(role=Role.user, content="Hi")], + tools=[tool], + tool_choice="auto", + ) + assert payload["tool_choice"] == "auto" + + def test_tool_choice_is_omitted_without_tools(self, adapter, provider): + payload = _prepare( + adapter, + provider, + [LLMMessage(role=Role.user, content="Hi")], + tool_choice="auto", + ) + assert "tool_choice" not in payload + + def test_tool_choice_specific(self, adapter, provider): + tool = AvailableTool( + function=AvailableFunction( + name="search", + description="Search", + parameters={"type": "object", "properties": {}}, + ) + ) + payload = _prepare( + adapter, + provider, + [LLMMessage(role=Role.user, content="Hi")], + tools=[tool], + tool_choice=tool, + ) + assert payload["tool_choice"] == {"type": "function", "name": "search"} + + @pytest.mark.parametrize( + ("thinking", "expected_effort"), + [ + ("off", "none"), + ("low", "low"), + ("medium", "medium"), + ("high", "high"), + ("max", "xhigh"), + ], + ) + def test_thinking_sets_reasoning_effort( + self, adapter, provider, thinking, expected_effort + ): + payload = _prepare( + adapter, + provider, + [LLMMessage(role=Role.user, content="Hi")], + thinking=thinking, + ) + assert payload["reasoning"] == {"effort": expected_effort} + + def test_non_leading_system_message_is_preserved(self, adapter, provider): + payload = _prepare( + adapter, + provider, + [ + LLMMessage(role=Role.user, content="Hi"), + LLMMessage(role=Role.system, content="Later system prompt"), + ], + ) + assert payload["input"] == [ + {"role": "user", "content": "Hi"}, + {"role": "system", "content": "Later system prompt"}, + ] + + def test_build_headers_with_api_key(self, adapter): + headers = adapter.build_headers("secret") + assert headers == { + "Content-Type": "application/json", + "Authorization": "Bearer secret", + } + + +class TestParseNonStreamingResponse: + def test_simple_text_response(self, adapter, provider): + data = { + "id": "resp_123", + "object": "response", + "output": [ + { + "type": "message", + "content": [{"type": "output_text", "text": "Hello!"}], + "role": "assistant", + } + ], + "usage": {"input_tokens": 10, "output_tokens": 5}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "Hello!" + assert chunk.message.role == Role.assistant + assert chunk.usage.prompt_tokens == 10 + assert chunk.usage.completion_tokens == 5 + + def test_function_call_response(self, adapter, provider): + data = { + "id": "resp_456", + "object": "response", + "output": [ + { + "type": "function_call", + "call_id": "call_789", + "name": "get_weather", + "arguments": '{"location": "Paris"}', + } + ], + "usage": {"input_tokens": 20, "output_tokens": 10}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.tool_calls is not None + assert len(chunk.message.tool_calls) == 1 + tc = chunk.message.tool_calls[0] + assert tc.id == "call_789" + assert tc.index == 0 + assert tc.function.name == "get_weather" + assert tc.function.arguments == '{"location": "Paris"}' + + def test_function_call_response_uses_id_when_call_id_missing( + self, adapter, provider + ): + data = { + "id": "resp_456", + "object": "response", + "output": [ + { + "type": "function_call", + "id": "fc_789", + "name": "get_weather", + "arguments": '{"location": "Paris"}', + } + ], + "usage": {"input_tokens": 20, "output_tokens": 10}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.tool_calls is not None + tc = chunk.message.tool_calls[0] + assert tc.id == "fc_789" + assert tc.index == 0 + assert tc.function.name == "get_weather" + assert tc.function.arguments == '{"location": "Paris"}' + + def test_commentary_phase_becomes_reasoning_content(self, adapter, provider): + data = { + "id": "resp_thinking", + "object": "response", + "output": [ + { + "type": "message", + "phase": "commentary", + "content": [{"type": "output_text", "text": "Let me think..."}], + "role": "assistant", + }, + { + "type": "message", + "phase": "final_answer", + "content": [{"type": "output_text", "text": "Hello!"}], + "role": "assistant", + }, + ], + "usage": {"input_tokens": 50, "output_tokens": 30}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "Hello!" + assert chunk.message.reasoning_content == "Let me think..." + + def test_invalid_non_streaming_response_schema_raises(self, adapter, provider): + data = {"id": "resp_invalid", "object": "response", "output": "not-a-list"} + + with pytest.raises(ValidationError): + adapter.parse_response(data, provider) + + def test_invalid_message_item_content_schema_raises(self, adapter, provider): + data = { + "id": "resp_invalid", + "object": "response", + "output": [ + {"type": "message", "role": "assistant", "content": "not-a-list"} + ], + } + + with pytest.raises(ValidationError): + adapter.parse_response(data, provider) + + def test_commentary_summary_blocks_become_reasoning_content( + self, adapter, provider + ): + data = { + "id": "resp_thinking", + "object": "response", + "output": [ + { + "type": "message", + "phase": "commentary", + "content": [ + {"type": "summary_text", "text": "Need more context."}, + {"type": "reasoning_summary_text", "text": " Compare options."}, + ], + "role": "assistant", + }, + { + "type": "message", + "phase": "final_answer", + "content": [{"type": "output_text", "text": "Done."}], + "role": "assistant", + }, + ], + "usage": {"input_tokens": 50, "output_tokens": 30}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "Done." + assert chunk.message.reasoning_content == "Need more context. Compare options." + + def test_commentary_mixed_blocks_do_not_leak_into_assistant_content( + self, adapter, provider + ): + data = { + "id": "resp_thinking", + "object": "response", + "output": [ + { + "type": "message", + "phase": "commentary", + "content": [ + {"type": "output_text", "text": "Let me think."}, + {"type": "summary_text", "text": " Need more context."}, + {"type": "reasoning_summary_text", "text": " Compare options."}, + ], + "role": "assistant", + }, + { + "type": "message", + "phase": "final_answer", + "content": [{"type": "output_text", "text": "Done."}], + "role": "assistant", + }, + ], + "usage": {"input_tokens": 50, "output_tokens": 30}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "Done." + assert ( + chunk.message.reasoning_content + == "Let me think. Need more context. Compare options." + ) + + def test_reasoning_summary_preserved_without_exposing_encrypted_content( + self, adapter, provider + ): + data = { + "id": "resp_reasoning", + "object": "response", + "output": [ + { + "type": "reasoning", + "encrypted_content": "enc:abc", + "summary": [ + {"type": "summary_text", "text": "Need to compare options."} + ], + }, + { + "type": "message", + "phase": "final_answer", + "content": [{"type": "output_text", "text": "Done."}], + "role": "assistant", + }, + ], + "usage": {"input_tokens": 50, "output_tokens": 30}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "Done." + assert chunk.message.reasoning_content == "Need to compare options." + assert chunk.message.reasoning_state == ["enc:abc"] + + def test_invalid_reasoning_item_schema_raises(self, adapter, provider): + data = { + "id": "resp_invalid", + "object": "response", + "output": [ + { + "type": "reasoning", + "encrypted_content": "enc:abc", + "summary": "not-a-list", + } + ], + } + + with pytest.raises(ValidationError): + adapter.parse_response(data, provider) + + def test_mixed_message_and_function_call(self, adapter, provider): + data = { + "id": "resp_mixed", + "object": "response", + "output": [ + { + "type": "message", + "content": [{"type": "output_text", "text": "Let me check."}], + "role": "assistant", + }, + { + "type": "function_call", + "call_id": "call_abc", + "name": "search", + "arguments": '{"q": "test"}', + }, + ], + "usage": {"input_tokens": 15, "output_tokens": 8}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "Let me check." + assert chunk.message.tool_calls is not None + assert chunk.message.tool_calls[0].index == 1 + assert chunk.message.tool_calls[0].function.name == "search" + + +class TestParseStreamingEvents: + def test_text_delta(self, adapter, provider): + data = { + "type": "response.output_text.delta", + "output_index": 0, + "content_index": 0, + "delta": "Hello", + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "Hello" + + def test_function_call_args_delta(self, adapter, provider): + data = { + "type": "response.function_call_arguments.delta", + "output_index": 0, + "call_id": "call_123", + "delta": '{"loc', + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "" + assert chunk.message.tool_calls is None + + def test_function_call_args_delta_requires_output_index(self, adapter, provider): + with pytest.raises(ValueError, match="Tool call chunk missing index"): + adapter.parse_response( + { + "type": "response.function_call_arguments.delta", + "call_id": "call_123", + "delta": '{"loc', + }, + provider, + ) + + def test_function_call_args_empty_delta_without_metadata_returns_empty_chunk( + self, adapter, provider + ): + chunk = adapter.parse_response( + {"type": "response.function_call_arguments.delta", "delta": ""}, provider + ) + assert chunk.message.content == "" + assert chunk.message.tool_calls is None + + def test_function_call_args_done_emits_missing_tool_call_data( + self, adapter, provider + ): + data = { + "type": "response.function_call_arguments.done", + "output_index": 0, + "call_id": "call_123", + "name": "search", + "arguments": '{"q": "test"}', + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.tool_calls is not None + tool_call = chunk.message.tool_calls[0] + assert tool_call.id == "call_123" + assert tool_call.index == 0 + assert tool_call.function.name == "search" + assert tool_call.function.arguments == '{"q": "test"}' + + def test_function_call_args_done_after_deltas_emits_full_arguments( + self, adapter, provider + ): + adapter.parse_response( + { + "type": "response.function_call_arguments.delta", + "output_index": 0, + "call_id": "call_123", + "name": "search", + "delta": '{"q": "test"}', + }, + provider, + ) + + chunk = adapter.parse_response( + { + "type": "response.function_call_arguments.done", + "output_index": 0, + "call_id": "call_123", + "name": "search", + "arguments": '{"q": "test"}', + }, + provider, + ) + assert chunk.message.tool_calls is not None + tool_call = chunk.message.tool_calls[0] + assert tool_call.id == "call_123" + assert tool_call.index == 0 + assert tool_call.function.name == "search" + assert tool_call.function.arguments == '{"q": "test"}' + + def test_function_call_args_done_after_partial_item_snapshot_emits_full_arguments( + self, adapter, provider + ): + added_chunk = adapter.parse_response( + { + "type": "response.output_item.added", + "output_index": 0, + "item": { + "type": "function_call", + "call_id": "call_123", + "name": "search", + "arguments": '{"q": "te', + }, + }, + provider, + ) + assert added_chunk.message.tool_calls is not None + assert added_chunk.message.tool_calls[0].function.arguments == "" + + adapter.parse_response( + { + "type": "response.function_call_arguments.delta", + "output_index": 0, + "call_id": "call_123", + "name": "search", + "delta": 'st"}', + }, + provider, + ) + + chunk = adapter.parse_response( + { + "type": "response.function_call_arguments.done", + "output_index": 0, + "call_id": "call_123", + "name": "search", + "arguments": '{"q": "test"}', + }, + provider, + ) + assert chunk.message.tool_calls is not None + tool_call = chunk.message.tool_calls[0] + assert tool_call.id == "call_123" + assert tool_call.index == 0 + assert tool_call.function.name == "search" + assert tool_call.function.arguments == '{"q": "test"}' + + def test_function_call_args_done_uses_full_arguments_on_mismatch( + self, adapter, provider, caplog + ): + adapter.parse_response( + { + "type": "response.function_call_arguments.delta", + "output_index": 0, + "call_id": "call_123", + "name": "search", + "delta": '{"q":"test"}', + }, + provider, + ) + + with caplog.at_level("WARNING"): + chunk = adapter.parse_response( + { + "type": "response.function_call_arguments.done", + "output_index": 0, + "call_id": "call_123", + "name": "search", + "arguments": '{"q": "test"}', + }, + provider, + ) + + assert "tool call arguments mismatch" in caplog.text + assert chunk.message.tool_calls is not None + assert chunk.message.tool_calls[0].function.arguments == '{"q": "test"}' + + def test_output_item_added_function_call(self, adapter, provider): + data = { + "type": "response.output_item.added", + "output_index": 0, + "item": { + "type": "function_call", + "call_id": "call_456", + "name": "bash", + "arguments": "", + }, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.tool_calls is not None + assert chunk.message.tool_calls[0].id == "call_456" + assert chunk.message.tool_calls[0].function.name == "bash" + + def test_output_item_added_invalid_function_call_item_schema_raises( + self, adapter, provider + ): + with pytest.raises(ValidationError): + adapter.parse_response( + { + "type": "response.output_item.added", + "output_index": 0, + "item": { + "type": "function_call", + "call_id": "call_456", + "name": "bash", + "arguments": {}, + }, + }, + provider, + ) + + def test_output_item_added_function_call_requires_output_index( + self, adapter, provider + ): + with pytest.raises(ValueError, match="Tool call chunk missing index"): + adapter.parse_response( + { + "type": "response.output_item.added", + "item": { + "type": "function_call", + "call_id": "call_456", + "name": "bash", + "arguments": "", + }, + }, + provider, + ) + + def test_output_item_added_message(self, adapter, provider): + data = { + "type": "response.output_item.added", + "output_index": 0, + "item": {"type": "message", "role": "assistant"}, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "" + assert chunk.message.tool_calls is None + + def test_output_item_done_function_call_emits_missing_arguments( + self, adapter, provider + ): + adapter.parse_response( + { + "type": "response.output_item.added", + "output_index": 0, + "item": { + "type": "function_call", + "call_id": "call_456", + "name": "bash", + "arguments": "", + }, + }, + provider, + ) + + chunk = adapter.parse_response( + { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "function_call", + "call_id": "call_456", + "name": "bash", + "arguments": '{"cmd": "pwd"}', + }, + }, + provider, + ) + assert chunk.message.tool_calls is not None + tool_call = chunk.message.tool_calls[0] + assert tool_call.id == "call_456" + assert tool_call.index == 0 + assert tool_call.function.name == "bash" + assert tool_call.function.arguments == '{"cmd": "pwd"}' + + def test_output_item_done_after_buffered_arguments_emits_full_arguments( + self, adapter, provider + ): + adapter.parse_response( + { + "type": "response.output_item.added", + "output_index": 0, + "item": { + "type": "function_call", + "call_id": "call_456", + "name": "bash", + "arguments": "", + }, + }, + provider, + ) + adapter.parse_response( + { + "type": "response.function_call_arguments.delta", + "output_index": 0, + "call_id": "call_456", + "name": "bash", + "delta": '{"cmd": "pwd"}', + }, + provider, + ) + + chunk = adapter.parse_response( + { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "function_call", + "call_id": "call_456", + "name": "bash", + "arguments": '{"cmd": "pwd"}', + }, + }, + provider, + ) + assert chunk.message.tool_calls is not None + tool_call = chunk.message.tool_calls[0] + assert tool_call.id == "call_456" + assert tool_call.index == 0 + assert tool_call.function.name == "bash" + assert tool_call.function.arguments == '{"cmd": "pwd"}' + + def test_output_item_done_after_partial_item_snapshot_emits_full_arguments( + self, adapter, provider + ): + added_chunk = adapter.parse_response( + { + "type": "response.output_item.added", + "output_index": 0, + "item": { + "type": "function_call", + "call_id": "call_456", + "name": "bash", + "arguments": '{"cmd": "p', + }, + }, + provider, + ) + assert added_chunk.message.tool_calls is not None + assert added_chunk.message.tool_calls[0].function.arguments == "" + + adapter.parse_response( + { + "type": "response.function_call_arguments.delta", + "output_index": 0, + "call_id": "call_456", + "name": "bash", + "delta": 'wd"}', + }, + provider, + ) + + chunk = adapter.parse_response( + { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "function_call", + "call_id": "call_456", + "name": "bash", + "arguments": '{"cmd": "pwd"}', + }, + }, + provider, + ) + assert chunk.message.tool_calls is not None + tool_call = chunk.message.tool_calls[0] + assert tool_call.id == "call_456" + assert tool_call.index == 0 + assert tool_call.function.name == "bash" + assert tool_call.function.arguments == '{"cmd": "pwd"}' + + def test_output_item_done_after_done_emits_no_duplicate_args( + self, adapter, provider + ): + adapter.parse_response( + { + "type": "response.output_item.added", + "output_index": 0, + "item": { + "type": "function_call", + "call_id": "call_456", + "name": "bash", + "arguments": "", + }, + }, + provider, + ) + adapter.parse_response( + { + "type": "response.function_call_arguments.done", + "output_index": 0, + "call_id": "call_456", + "name": "bash", + "arguments": '{"cmd": "pwd"}', + }, + provider, + ) + + chunk = adapter.parse_response( + { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "function_call", + "call_id": "call_456", + "name": "bash", + "arguments": '{"cmd": "pwd"}', + }, + }, + provider, + ) + assert chunk.message.content == "" + assert chunk.message.tool_calls is None + + def test_response_completed(self, adapter, provider): + data = { + "type": "response.completed", + "response": { + "id": "resp_123", + "output": [ + { + "type": "reasoning", + "encrypted_content": "enc:streamed", + "summary": [], + }, + { + "type": "message", + "content": [{"type": "output_text", "text": "Done!"}], + "role": "assistant", + }, + ], + "usage": {"input_tokens": 50, "output_tokens": 25}, + }, + } + chunk = adapter.parse_response(data, provider) + # Streaming completed event only carries usage; content was already + # delivered via delta events, so message should be empty. + assert chunk.message.content == "" + assert chunk.message.reasoning_state == ["enc:streamed"] + assert chunk.usage.prompt_tokens == 50 + assert chunk.usage.completion_tokens == 25 + + def test_response_incomplete_uses_terminal_usage(self, adapter, provider): + data = { + "type": "response.incomplete", + "response": { + "id": "resp_123", + "status": "incomplete", + "incomplete_details": {"reason": "max_output_tokens"}, + "usage": {"input_tokens": 50, "output_tokens": 25}, + }, + } + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "" + assert chunk.usage.prompt_tokens == 50 + assert chunk.usage.completion_tokens == 25 + + def test_commentary_deltas_become_reasoning_content(self, adapter, provider): + adapter.parse_response( + { + "type": "response.output_item.added", + "output_index": 0, + "item": {"type": "message", "phase": "commentary", "role": "assistant"}, + }, + provider, + ) + chunk = adapter.parse_response( + { + "type": "response.output_text.delta", + "output_index": 0, + "content_index": 0, + "delta": "Thinking...", + }, + provider, + ) + assert chunk.message.content == "" + assert chunk.message.reasoning_content == "Thinking..." + + adapter.parse_response( + { + "type": "response.output_item.added", + "output_index": 1, + "item": { + "type": "message", + "phase": "final_answer", + "role": "assistant", + }, + }, + provider, + ) + chunk = adapter.parse_response( + { + "type": "response.output_text.delta", + "output_index": 1, + "content_index": 0, + "delta": "Hello!", + }, + provider, + ) + assert chunk.message.content == "Hello!" + assert chunk.message.reasoning_content is None + + def test_reasoning_summary_delta_emits_reasoning_content(self, adapter, provider): + chunk = adapter.parse_response( + { + "type": "response.reasoning_summary_text.delta", + "output_index": 0, + "summary_index": 0, + "delta": "Need more context.", + }, + provider, + ) + assert chunk.message.content == "" + assert chunk.message.reasoning_content == "Need more context." + + def test_summary_text_delta_emits_reasoning_content(self, adapter, provider): + chunk = adapter.parse_response( + { + "type": "response.summary_text.delta", + "output_index": 0, + "summary_index": 0, + "delta": "Need more context.", + }, + provider, + ) + assert chunk.message.content == "" + assert chunk.message.reasoning_content == "Need more context." + + def test_commentary_state_resets_on_new_stream(self, adapter, provider): + # Register commentary index + adapter.parse_response( + { + "type": "response.output_item.added", + "output_index": 0, + "item": {"type": "message", "phase": "commentary", "role": "assistant"}, + }, + provider, + ) + # New stream resets state + adapter.parse_response( + { + "type": "response.created", + "response": {"id": "resp_new", "output": [], "usage": None}, + }, + provider, + ) + # Index 0 should no longer be suppressed + chunk = adapter.parse_response( + { + "type": "response.output_text.delta", + "output_index": 0, + "content_index": 0, + "delta": "Fresh start", + }, + provider, + ) + assert chunk.message.content == "Fresh start" + + def test_unknown_event_returns_empty_chunk(self, adapter, provider): + data = {"type": "response.content_part.added", "output_index": 0} + chunk = adapter.parse_response(data, provider) + assert chunk.message.content == "" + assert chunk.usage.prompt_tokens == 0 + + def test_error_event_raises_runtime_error(self, adapter, provider): + with pytest.raises(RuntimeError, match="OpenAI Responses stream error"): + adapter.parse_response( + { + "type": "error", + "error": {"type": "server_error", "message": "backend failed"}, + }, + provider, + ) + + def test_invalid_error_payload_schema_raises(self, adapter, provider): + with pytest.raises(ValidationError): + adapter.parse_response({"type": "error", "error": "not-a-dict"}, provider) + + +class TestGenericBackendIntegration: + """Test OpenAIResponsesAdapter via GenericBackend + respx mocks.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "base_url,json_response,result_data", + [ + *SIMPLE_CONVERSATION_PARAMS, + *TOOL_CONVERSATION_PARAMS, + *COMMENTARY_CONVERSATION_PARAMS, + ], + ) + async def test_complete( + 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( + return_value=httpx.Response(status_code=200, json=json_response) + ) + backend = _make_backend(base_url) + model = _make_model() + 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_chunk_matches(result, result_data) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "base_url,chunks,result_data", + [ + *STREAMED_SIMPLE_CONVERSATION_PARAMS, + *STREAMED_TOOL_CONVERSATION_PARAMS, + *STREAMED_COMMENTARY_CONVERSATION_PARAMS, + ], + ) + async def test_complete_streaming( + 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( + return_value=httpx.Response( + status_code=200, + stream=httpx.ByteStream(stream=b"\n\n".join(chunks)), + headers={"Content-Type": "text/event-stream"}, + ) + ) + backend = _make_backend(base_url) + model = _make_model() + messages = [LLMMessage(role=Role.user, content="Just say hi")] + + results: list[LLMChunk] = [] + async for result in backend.complete_streaming( + model=model, + messages=messages, + temperature=0.2, + tools=None, + max_tokens=None, + tool_choice=None, + extra_headers=None, + ): + results.append(result) + + for result, expected_result in zip(results, result_data, strict=True): + _assert_chunk_matches(result, expected_result) + + @pytest.mark.asyncio + async def test_streaming_payload_includes_stream_flag(self): + base_url = OPENAI_RESPONSES_TEST_BASE_URL + with respx.mock(base_url=base_url) as mock_api: + route = mock_api.post("/v1/responses").mock( + return_value=httpx.Response( + status_code=200, + stream=httpx.ByteStream( + b'data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"hi"}\n\n' + b'data: {"type":"response.completed","response":{"id":"resp_1","output":[{"type":"message","content":[{"type":"output_text","text":"hi"}],"role":"assistant"}],"usage":{"input_tokens":10,"output_tokens":5}}}\n\n' + b"data: [DONE]\n\n" + ), + headers={"Content-Type": "text/event-stream"}, + ) + ) + backend = _make_backend(base_url) + model = _make_model() + messages = [LLMMessage(role=Role.user, content="hi")] + + async for _ in backend.complete_streaming( + model=model, + messages=messages, + temperature=0.2, + tools=None, + max_tokens=None, + tool_choice=None, + extra_headers=None, + ): + pass + + assert route.called + request = route.calls.last.request + payload = json.loads(request.content) + assert payload["stream"] is True + # Responses API does not use stream_options + assert "stream_options" not in payload diff --git a/tests/banner/test_banner_initial_state.py b/tests/banner/test_banner_initial_state.py index 455b2ad..262167b 100644 --- a/tests/banner/test_banner_initial_state.py +++ b/tests/banner/test_banner_initial_state.py @@ -1,21 +1,29 @@ -"""Tests for the Banner widget initial state with connectors/MCP.""" - from __future__ import annotations -from unittest.mock import Mock, patch +from unittest.mock import Mock -from vibe.cli.textual_ui.widgets.banner.banner import ( - Banner, - BannerState, - _connector_count, - _pluralize, -) +from vibe.cli.textual_ui.widgets.banner.banner import Banner, BannerState, _pluralize from vibe.core.config import VibeConfig +from vibe.core.config._settings import ModelConfig, ThinkingLevel from vibe.core.skills.manager import SkillManager -from vibe.core.tools.connectors.connector_registry import ConnectorRegistry from vibe.core.tools.mcp.registry import MCPRegistry +def _make_mock_config( + active_model: str = "test-model", thinking: ThinkingLevel = "off" +) -> Mock: + config = Mock(spec=VibeConfig) + config.active_model = active_model + config.models = [active_model] + config.mcp_servers = [] + config.connectors = [] + config.disable_welcome_banner_animation = False + config.get_active_model.return_value = ModelConfig( + name=active_model, provider="mistral", alias=active_model, thinking=thinking + ) + return config + + class TestBannerInitialState: """Test that Banner properly displays initial state including connectors/MCP.""" @@ -28,54 +36,27 @@ class TestBannerInitialState: assert _pluralize(1, "MCP server") == "1 MCP server" assert _pluralize(2, "connector") == "2 connectors" - def test_connector_count_with_registry(self) -> None: - """Test _connector_count with a populated registry.""" - registry = Mock(spec=ConnectorRegistry) - registry.connector_count = 3 - assert _connector_count(registry) == 3 - - def test_connector_count_without_registry(self) -> None: - """Test _connector_count with None registry.""" - assert _connector_count(None) == 0 - def test_banner_initial_state_includes_connectors(self) -> None: - """Test that Banner._initial_state includes connector count.""" - config = Mock(spec=VibeConfig) - config.active_model = "test-model" - config.models = ["test-model"] - config.mcp_servers = [] - config.disable_welcome_banner_animation = False - skill_manager = Mock(spec=SkillManager) skill_manager.custom_skills_count = 0 mcp_registry = Mock(spec=MCPRegistry) mcp_registry.count_loaded.return_value = 0 - connector_registry = Mock(spec=ConnectorRegistry) - connector_registry.connector_count = 5 - banner = Banner( - config=config, + config=_make_mock_config(), skill_manager=skill_manager, mcp_registry=mcp_registry, - connector_registry=connector_registry, + connectors_count=5, ) - assert banner._initial_state.active_model == "test-model" + assert banner._initial_state.active_model == "test-model[off]" assert banner._initial_state.models_count == 1 assert banner._initial_state.mcp_servers_count == 0 assert banner._initial_state.connectors_count == 5 assert banner._initial_state.skills_count == 0 - def test_banner_initial_state_with_none_connector_registry(self) -> None: - """Test that Banner._initial_state handles None connector registry.""" - config = Mock(spec=VibeConfig) - config.active_model = "test-model" - config.models = ["test-model"] - config.mcp_servers = [] - config.disable_welcome_banner_animation = False - + def test_banner_initial_state_with_no_connectors(self) -> None: skill_manager = Mock(spec=SkillManager) skill_manager.custom_skills_count = 0 @@ -83,23 +64,14 @@ class TestBannerInitialState: mcp_registry.count_loaded.return_value = 0 banner = Banner( - config=config, + config=_make_mock_config(), skill_manager=skill_manager, mcp_registry=mcp_registry, - connector_registry=None, ) assert banner._initial_state.connectors_count == 0 - def test_format_meta_counts_includes_connectors(self) -> None: - """Test that _format_meta_counts includes connector count when > 0.""" - # Test _format_meta_counts by directly calling it on a Banner instance - config = Mock(spec=VibeConfig) - config.active_model = "test-model" - config.models = [] # Must be a list for len() to work - config.mcp_servers = [] - config.disable_welcome_banner_animation = False - + def test_banner_shows_thinking_level(self) -> None: skill_manager = Mock(spec=SkillManager) skill_manager.custom_skills_count = 0 @@ -107,10 +79,24 @@ class TestBannerInitialState: mcp_registry.count_loaded.return_value = 0 banner = Banner( - config=config, + config=_make_mock_config(thinking="max"), + skill_manager=skill_manager, + mcp_registry=mcp_registry, + ) + + assert banner._initial_state.active_model == "test-model[max]" + + def test_format_meta_counts_includes_connectors(self) -> None: + skill_manager = Mock(spec=SkillManager) + skill_manager.custom_skills_count = 0 + + mcp_registry = Mock(spec=MCPRegistry) + mcp_registry.count_loaded.return_value = 0 + + banner = Banner( + config=_make_mock_config(), skill_manager=skill_manager, mcp_registry=mcp_registry, - connector_registry=None, ) # Now test _format_meta_counts by setting state @@ -134,34 +120,19 @@ class TestBannerInitialState: assert "5 skills" in result -class TestBannerWithEnabledConnectors: - """Integration tests for Banner with EXPERIMENTAL_ENABLE_CONNECTORS=1.""" - - @patch("vibe.core.tools.connectors.connectors_enabled") - def test_connectors_enabled_flag(self, mock_enabled: Mock) -> None: - """Test that connector count is read when enabled.""" - mock_enabled.return_value = True - - config = Mock(spec=VibeConfig) - config.active_model = "test-model" - config.models = ["test-model"] - config.mcp_servers = [] - config.disable_welcome_banner_animation = False - +class TestBannerConnectorsCount: + def test_connectors_count_passed_through(self) -> None: skill_manager = Mock(spec=SkillManager) skill_manager.custom_skills_count = 0 mcp_registry = Mock(spec=MCPRegistry) mcp_registry.count_loaded.return_value = 0 - connector_registry = Mock(spec=ConnectorRegistry) - connector_registry.connector_count = 5 - banner = Banner( - config=config, + config=_make_mock_config(), skill_manager=skill_manager, mcp_registry=mcp_registry, - connector_registry=connector_registry, + connectors_count=5, ) assert banner._initial_state.connectors_count == 5 diff --git a/tests/cli/plan_offer/test_decide_plan_offer.py b/tests/cli/plan_offer/test_decide_plan_offer.py index fec6772..95a1d40 100644 --- a/tests/cli/plan_offer/test_decide_plan_offer.py +++ b/tests/cli/plan_offer/test_decide_plan_offer.py @@ -8,6 +8,7 @@ import pytest from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway from vibe.cli.plan_offer.decide_plan_offer import ( + PlanInfo, WhoAmIPlanType, decide_plan_offer, resolve_api_key_for_plan, @@ -171,3 +172,52 @@ def test_resolve_api_key_for_plan_with_missing_env_var() -> None: if previous_api_key is not None: environ["MISTRAL_API_KEY"] = previous_api_key + + +@pytest.mark.parametrize( + ("response", "expected"), + [ + ( + WhoAmIResponse( + plan_type=WhoAmIPlanType.CHAT, + plan_name="INDIVIDUAL", + prompt_switching_to_pro_plan=False, + ), + True, + ), + ( + WhoAmIResponse( + plan_type=WhoAmIPlanType.CHAT, + plan_name="INDIVIDUAL", + prompt_switching_to_pro_plan=True, + ), + False, + ), + ( + WhoAmIResponse( + plan_type=WhoAmIPlanType.API, + plan_name="FREE", + prompt_switching_to_pro_plan=False, + ), + False, + ), + ( + WhoAmIResponse( + plan_type=WhoAmIPlanType.MISTRAL_CODE, + plan_name="E", + prompt_switching_to_pro_plan=False, + ), + False, + ), + ], + ids=[ + "chat-plan-is-eligible", + "chat-plan-requiring-key-switch-is-ineligible", + "api-plan-is-ineligible", + "mistral-code-enterprise-is-ineligible", + ], +) +def test_teleport_eligibility_depends_on_chat_plan_and_current_key( + response: WhoAmIResponse, expected: bool +) -> None: + assert PlanInfo.from_response(response).is_teleport_eligible() is expected diff --git a/tests/cli/test_cache.py b/tests/cli/test_cache.py new file mode 100644 index 0000000..9197e08 --- /dev/null +++ b/tests/cli/test_cache.py @@ -0,0 +1,73 @@ +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_clipboard.py b/tests/cli/test_clipboard.py index d4832b7..b3c74c7 100644 --- a/tests/cli/test_clipboard.py +++ b/tests/cli/test_clipboard.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import subprocess from types import SimpleNamespace from typing import cast from unittest.mock import MagicMock, mock_open, patch @@ -16,6 +17,7 @@ from vibe.cli.clipboard import ( _copy_xclip, _read_clipboard, copy_selection_to_clipboard, + copy_text_to_clipboard, ) @@ -174,6 +176,44 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No ) +@patch("vibe.cli.clipboard._copy_to_clipboard") +def test_copy_text_to_clipboard_success( + mock_copy_to_clipboard: MagicMock, mock_app: MagicMock +) -> None: + result = copy_text_to_clipboard( + mock_app, "assistant text", success_message="Agent message copied" + ) + + assert result == "assistant text" + mock_copy_to_clipboard.assert_called_once_with("assistant text") + mock_app.notify.assert_called_once_with( + "Agent message copied", severity="information", timeout=2, markup=False + ) + + +@patch("vibe.cli.clipboard._copy_to_clipboard") +def test_copy_text_to_clipboard_shows_failure_when_clipboard_unavailable( + mock_copy_to_clipboard: MagicMock, mock_app: MagicMock +) -> None: + mock_copy_to_clipboard.side_effect = RuntimeError("All clipboard strategies failed") + + result = copy_text_to_clipboard(mock_app, "assistant text") + + assert result is None + mock_copy_to_clipboard.assert_called_once_with("assistant text") + mock_app.notify.assert_called_once_with( + "Failed to copy - clipboard not available", severity="warning", timeout=3 + ) + + +def test_copy_text_to_clipboard_returns_none_for_empty_text( + mock_app: MagicMock, +) -> None: + result = copy_text_to_clipboard(mock_app, "") + assert result is None + mock_app.notify.assert_not_called() + + def test_copy_to_clipboard_stops_after_verified_copy() -> None: """Stops iterating once _read_clipboard confirms the text landed.""" mock_first = MagicMock() @@ -244,21 +284,28 @@ def test_read_clipboard_skips_failing_reader() -> None: @patch("subprocess.run") def test_copy_pbcopy(mock_run: MagicMock) -> None: _copy_pbcopy("hello") - mock_run.assert_called_once_with(["pbcopy"], input=b"hello", check=True) + mock_run.assert_called_once_with( + ["pbcopy"], input=b"hello", check=True, stderr=subprocess.DEVNULL + ) @patch("subprocess.run") def test_copy_xclip(mock_run: MagicMock) -> None: _copy_xclip("hello") mock_run.assert_called_once_with( - ["xclip", "-selection", "clipboard"], input=b"hello", check=True + ["xclip", "-selection", "clipboard"], + input=b"hello", + check=True, + stderr=subprocess.DEVNULL, ) @patch("subprocess.run") def test_copy_wl_copy(mock_run: MagicMock) -> None: _copy_wl_copy("hello") - mock_run.assert_called_once_with(["wl-copy"], input=b"hello", check=True) + mock_run.assert_called_once_with( + ["wl-copy"], input=b"hello", check=True, stderr=subprocess.DEVNULL + ) def test_copy_methods_includes_available_commands() -> None: diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index f7bd65d..3a7bc2a 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -1,6 +1,20 @@ from __future__ import annotations -from vibe.cli.commands import Command, CommandRegistry +from vibe.cli.commands import Command, CommandAvailabilityContext, CommandRegistry +from vibe.cli.plan_offer.decide_plan_offer import PlanInfo +from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType + + +def _eligible_teleport_context() -> CommandAvailabilityContext: + return CommandAvailabilityContext( + vibe_code_enabled=True, + is_active_model_mistral=True, + plan_info=PlanInfo( + plan_type=WhoAmIPlanType.CHAT, + plan_name="INDIVIDUAL", + prompt_switching_to_pro_plan=False, + ), + ) class TestCommandRegistry: @@ -59,6 +73,26 @@ class TestCommandRegistry: assert registry.parse_command("/exit") is None assert registry.get_command_name("/help") == "help" + def test_teleport_command_hidden_without_eligible_context(self) -> None: + registry = CommandRegistry() + assert registry.get_command_name("/teleport") is None + assert registry.parse_command("/teleport") is None + + def test_teleport_command_registration_uses_resolved_context(self) -> None: + registry = CommandRegistry(availability_context=_eligible_teleport_context()) + assert registry.get_command_name("/teleport") == "teleport" + assert registry.has_command("teleport") + + def test_teleport_help_text_uses_resolved_context(self) -> None: + registry = CommandRegistry() + assert "/teleport" not in registry.get_help_text() + + eligible_registry = CommandRegistry( + availability_context=_eligible_teleport_context() + ) + assert eligible_registry.get("teleport") is not None + assert "/teleport" in eligible_registry.get_help_text() + def test_resume_command_registration(self) -> None: registry = CommandRegistry() assert registry.get_command_name("/resume") == "resume" diff --git a/tests/cli/test_copy_command.py b/tests/cli/test_copy_command.py new file mode 100644 index 0000000..3ecbf93 --- /dev/null +++ b/tests/cli/test_copy_command.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from tests.conftest import build_test_vibe_app +from vibe.cli.textual_ui.widgets.messages import AssistantMessage + + +@pytest.mark.asyncio +async def test_copy_command_copies_last_assistant_message() -> None: + app = build_test_vibe_app() + second_reply = "\n```python\nprint('second reply')\n```\n" + stripped_second_reply = second_reply.strip() + async with app.run_test() as pilot: + await app._mount_and_scroll(AssistantMessage("first reply")) + await app._mount_and_scroll(AssistantMessage(second_reply)) + with ( + patch( + "vibe.cli.textual_ui.app.copy_text_to_clipboard", + return_value=stripped_second_reply, + ) as mock_copy, + patch.object( + app.agent_loop.telemetry_client, "send_user_copied_text" + ) as mock_telemetry, + ): + handled = await app._handle_command("/copy") + await pilot.pause() + assert handled is True + assert stripped_second_reply != second_reply + mock_copy.assert_called_once_with( + app, + stripped_second_reply, + success_message="Last agent message copied to clipboard", + ) + mock_telemetry.assert_called_once_with(stripped_second_reply) + + +@pytest.mark.asyncio +async def test_copy_command_warns_when_no_assistant_message() -> None: + app = build_test_vibe_app() + + async with app.run_test() as pilot: + with patch.object(app, "notify") as mock_notify: + handled = await app._handle_command("/copy") + + await pilot.pause() + + assert handled is True + mock_notify.assert_called_once_with( + "No agent message available to copy", severity="warning", timeout=3 + ) diff --git a/tests/cli/test_copy_shortcuts.py b/tests/cli/test_copy_shortcuts.py index 43a1952..214eec6 100644 --- a/tests/cli/test_copy_shortcuts.py +++ b/tests/cli/test_copy_shortcuts.py @@ -4,13 +4,13 @@ from unittest.mock import patch import pytest -from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp +from tests.conftest import build_test_vibe_app, build_test_vibe_config @pytest.mark.asyncio async def test_ctrl_y_triggers_copy_selection() -> None: """Test that ctrl+y keybinding triggers copy_selection_to_clipboard.""" - app = BaseSnapshotTestApp() + app = build_test_vibe_app() with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy: async with app.run_test() as pilot: @@ -21,7 +21,7 @@ async def test_ctrl_y_triggers_copy_selection() -> None: @pytest.mark.asyncio async def test_ctrl_shift_c_triggers_copy_selection() -> None: """Test that ctrl+shift+c keybinding triggers copy_selection_to_clipboard.""" - app = BaseSnapshotTestApp() + app = build_test_vibe_app() with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy: async with app.run_test() as pilot: @@ -32,11 +32,7 @@ async def test_ctrl_shift_c_triggers_copy_selection() -> None: @pytest.mark.asyncio async def test_mouse_up_respects_autocopy_config_enabled() -> None: """Test that mouse up copies when autocopy_to_clipboard is True.""" - from tests.snapshots.base_snapshot_test_app import default_config - - config = default_config() - config.autocopy_to_clipboard = True - app = BaseSnapshotTestApp(config=config) + app = build_test_vibe_app(config=build_test_vibe_config(autocopy_to_clipboard=True)) with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy: async with app.run_test() as pilot: @@ -47,11 +43,9 @@ async def test_mouse_up_respects_autocopy_config_enabled() -> None: @pytest.mark.asyncio async def test_mouse_up_respects_autocopy_config_disabled() -> None: """Test that mouse up does not copy when autocopy_to_clipboard is False.""" - from tests.snapshots.base_snapshot_test_app import default_config - - config = default_config() - config.autocopy_to_clipboard = False - app = BaseSnapshotTestApp(config=config) + app = build_test_vibe_app( + config=build_test_vibe_config(autocopy_to_clipboard=False) + ) with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy: async with app.run_test() as pilot: diff --git a/tests/cli/test_feedback_bar.py b/tests/cli/test_feedback_bar.py index 6b3cd27..f13578c 100644 --- a/tests/cli/test_feedback_bar.py +++ b/tests/cli/test_feedback_bar.py @@ -6,27 +6,21 @@ from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar class TestFeedbackBarState: - def test_maybe_show_shows_when_random_below_threshold(self): + def test_show_activates(self): bar = FeedbackBar() bar.display = False bar._set_active = MagicMock() - with patch( - "vibe.cli.textual_ui.widgets.feedback_bar.random.random", return_value=0 - ): - bar.maybe_show() + bar.show() bar._set_active.assert_called_once_with(True) - def test_maybe_show_does_not_show_when_random_above_threshold(self): + def test_show_skips_when_already_displayed(self): bar = FeedbackBar() - bar.display = False + bar.display = True bar._set_active = MagicMock() - with patch( - "vibe.cli.textual_ui.widgets.feedback_bar.random.random", return_value=1.0 - ): - bar.maybe_show() + bar.show() bar._set_active.assert_not_called() diff --git a/tests/cli/test_feedback_bar_manager.py b/tests/cli/test_feedback_bar_manager.py new file mode 100644 index 0000000..c4bf91d --- /dev/null +++ b/tests/cli/test_feedback_bar_manager.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from pathlib import Path +import time +import tomllib +from unittest.mock import MagicMock, patch + +from vibe.cli.textual_ui.widgets.feedback_bar_manager import ( + _CACHE_SECTION, + _LAST_SHOWN_KEY, + FEEDBACK_COOLDOWN_SECONDS, + MIN_USER_MESSAGES_FOR_FEEDBACK, + FeedbackBarManager, +) +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.cli.textual_ui.widgets.feedback_bar_manager.CACHE_FILE", + GlobalPath(lambda: tmp_path / "cache.toml"), + ) + + +def _patch_probability(value: float): + return patch( + "vibe.cli.textual_ui.widgets.feedback_bar_manager.FEEDBACK_PROBABILITY", value + ) + + +def _make_agent_loop( + 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 + messages = [ + LLMMessage(role=Role.user, content=f"msg {i}") + for i in range(user_message_count) + ] + loop.messages = messages + return loop + + +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.cli.textual_ui.widgets.feedback_bar_manager.random.random", + return_value=0.0, + ), + ): + assert manager.should_show(_make_agent_loop()) 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.cli.textual_ui.widgets.feedback_bar_manager.random.random", + return_value=1.0, + ), + ): + assert manager.should_show(_make_agent_loop()) is False + + def test_does_not_show_within_cooldown(self, tmp_path: Path) -> None: + (tmp_path / "cache.toml").write_text( + f"[{_CACHE_SECTION}]\n{_LAST_SHOWN_KEY} = {int(time.time()) - 60}\n" + ) + manager = FeedbackBarManager() + with ( + _patch_cache_file(tmp_path), + _patch_probability(0.2), + patch( + "vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random", + return_value=0.0, + ), + ): + assert manager.should_show(_make_agent_loop()) is False + + def test_shows_after_cooldown_expires(self, tmp_path: Path) -> None: + (tmp_path / "cache.toml").write_text( + f"[{_CACHE_SECTION}]\n{_LAST_SHOWN_KEY} = {int(time.time()) - FEEDBACK_COOLDOWN_SECONDS - 1}\n" + ) + manager = FeedbackBarManager() + with ( + _patch_cache_file(tmp_path), + _patch_probability(0.2), + patch( + "vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random", + return_value=0.0, + ), + ): + assert manager.should_show(_make_agent_loop()) 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): + assert ( + manager.should_show(_make_agent_loop(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.cli.textual_ui.widgets.feedback_bar_manager.random.random", + return_value=0.0, + ), + ): + assert manager.should_show(_make_agent_loop(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.messages = [ + LLMMessage(role=Role.user, content="real"), + LLMMessage(role=Role.user, content="injected", injected=True), + LLMMessage(role=Role.assistant, content="reply"), + ] + manager = FeedbackBarManager() + with ( + _patch_cache_file(tmp_path), + _patch_probability(0.2), + patch( + "vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random", + return_value=0.0, + ), + ): + # Only 1 non-injected user message, below MIN_USER_MESSAGES_FOR_FEEDBACK + assert manager.should_show(loop) is False + + +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) + assert data[_CACHE_SECTION][_LAST_SHOWN_KEY] >= before diff --git a/tests/cli/test_mcp_app.py b/tests/cli/test_mcp_app.py index f00237b..132d1b9 100644 --- a/tests/cli/test_mcp_app.py +++ b/tests/cli/test_mcp_app.py @@ -150,9 +150,7 @@ class TestMCPAppInit: app = MCPApp(mcp_servers=servers, tool_manager=mgr) app._viewing_server = "srv" render_calls: list[str | None] = [] - app._refresh_view = lambda server_name, *, kind=None: render_calls.append( - server_name - ) + app._refresh_view = lambda server_name, **_kw: render_calls.append(server_name) app.action_back() assert render_calls == [None] @@ -161,9 +159,7 @@ class TestMCPAppInit: app = MCPApp(mcp_servers=[], tool_manager=mgr) app._viewing_server = None render_calls: list[str | None] = [] - app._refresh_view = lambda server_name, *, kind=None: render_calls.append( - server_name - ) + app._refresh_view = lambda server_name, **_kw: render_calls.append(server_name) app.action_back() assert render_calls == [] @@ -176,17 +172,13 @@ class TestMCPAppInit: mcp_servers=servers, tool_manager=mgr, refresh_callback=refresh_callback ) app._viewing_server = "srv" - render_calls: list[tuple[str | None, str | None]] = [] - app._refresh_view = lambda server_name, *, kind=None: render_calls.append(( - server_name, - kind, - )) + app._set_help_text = MagicMock() app.run_worker = MagicMock() await app.action_refresh() assert app._status_message == "Refreshing..." - assert render_calls == [("srv", None)] + assert app._refreshing is True app.run_worker.assert_called_once() def test_on_worker_state_changed_updates_after_refresh(self) -> None: diff --git a/tests/cli/test_programmatic_setup.py b/tests/cli/test_programmatic_setup.py new file mode 100644 index 0000000..9f585e5 --- /dev/null +++ b/tests/cli/test_programmatic_setup.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +import pytest + +from vibe.cli import cli as cli_mod, entrypoint as entrypoint_mod +from vibe.core.config import MissingAPIKeyError +from vibe.core.trusted_folders import trusted_folders_manager + + +def _make_args(**overrides: object) -> argparse.Namespace: + base: dict[str, object] = { + "initial_prompt": None, + "prompt": "hello", + "max_turns": None, + "max_price": None, + "enabled_tools": None, + "output": "text", + "agent": "default", + "setup": False, + "workdir": None, + "trust": False, + "teleport": False, + "continue_session": False, + "resume": None, + } + base.update(overrides) + return argparse.Namespace(**base) + + +def test_programmatic_mode_does_not_run_onboarding_on_missing_api_key( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + def boom() -> None: + raise MissingAPIKeyError("MISTRAL_API_KEY", "mistral") + + monkeypatch.setattr(cli_mod.VibeConfig, "load", staticmethod(boom)) + + sentinel: dict[str, bool] = {"called": False} + + def fail_onboarding(*_args: object, **_kwargs: object) -> None: + sentinel["called"] = True + + monkeypatch.setattr(cli_mod, "run_onboarding", fail_onboarding) + + with pytest.raises(SystemExit) as exc_info: + cli_mod.load_config_or_exit(interactive=False) + + assert exc_info.value.code == 1 + assert sentinel["called"] is False + err = capsys.readouterr().err + assert "MISTRAL_API_KEY" in err + assert "vibe --setup" in err + + +def test_interactive_mode_still_runs_onboarding_on_missing_api_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Replace VibeConfig.load with a stub that fails the first time. + state = {"raised": False} + + def fake_load() -> object: + if not state["raised"]: + state["raised"] = True + raise MissingAPIKeyError("MISTRAL_API_KEY", "mistral") + return "config-sentinel" + + monkeypatch.setattr(cli_mod.VibeConfig, "load", staticmethod(fake_load)) + + onboarding_called: list[bool] = [] + monkeypatch.setattr( + cli_mod, "run_onboarding", lambda *a, **k: onboarding_called.append(True) + ) + + result = cli_mod.load_config_or_exit(interactive=True) + assert onboarding_called == [True] + assert result == "config-sentinel" + + +def test_warn_if_workdir_untrusted_writes_stderr_when_project_config_present( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + project = tmp_path / "proj" + project.mkdir() + (project / "AGENTS.md").write_text("hello", encoding="utf-8") + monkeypatch.chdir(project) + + cli_mod.warn_if_workdir_trust_is_unset() + + err = " ".join(capsys.readouterr().err.split()) + assert "not trusted" in err + assert "AGENTS.md" in err + assert "--trust" in err + + +def test_warn_if_workdir_untrusted_silent_when_already_trusted( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + project = tmp_path / "proj" + project.mkdir() + (project / "AGENTS.md").write_text("hello", encoding="utf-8") + monkeypatch.chdir(project) + + trusted_folders_manager.add_trusted(project) + + cli_mod.warn_if_workdir_trust_is_unset() + + assert capsys.readouterr().err == "" + + +def test_warn_if_workdir_untrusted_silent_when_no_project_config( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + project = tmp_path / "proj" + project.mkdir() + monkeypatch.chdir(project) + + cli_mod.warn_if_workdir_trust_is_unset() + + assert capsys.readouterr().err == "" + + +def test_trust_flag_trusts_cwd_for_session_only( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + project = tmp_path / "proj" + project.mkdir() + monkeypatch.chdir(project) + + 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)) + ) + + with pytest.raises(SystemExit) as exc_info: + entrypoint_mod.main() + assert exc_info.value.code == 0 + + assert trusted_folders_manager.is_trusted(project) is True + # --trust must NOT persist to trusted_folders.toml. + assert trusted_folders_manager._trusted == [] + assert str(project.resolve()) in trusted_folders_manager._session_trusted + + +def test_trust_flag_works_in_programmatic_mode( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + project = tmp_path / "proj" + project.mkdir() + monkeypatch.chdir(project) + + args = _make_args(trust=True, prompt="run") + monkeypatch.setattr(entrypoint_mod, "parse_arguments", lambda: args) + monkeypatch.setattr( + entrypoint_mod, + "check_and_resolve_trusted_folder", + lambda _cwd: pytest.fail("must not prompt in -p 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)) + ) + + with pytest.raises(SystemExit): + entrypoint_mod.main() + + assert trusted_folders_manager.is_trusted(project) is True + assert trusted_folders_manager._trusted == [] + + +def test_session_trust_does_not_write_to_disk( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + trust_file = tmp_path / "trusted_folders.toml" + monkeypatch.setattr(trusted_folders_manager, "_file_path", trust_file) + project = tmp_path / "proj" + project.mkdir() + + trusted_folders_manager.trust_for_session(project) + + assert trusted_folders_manager.is_trusted(project) is True + assert not trust_file.exists() diff --git a/tests/cli/test_stderr_guard.py b/tests/cli/test_stderr_guard.py new file mode 100644 index 0000000..043829f --- /dev/null +++ b/tests/cli/test_stderr_guard.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import os +import sys +from unittest.mock import patch + +import pytest + +from vibe.cli.stderr_guard import stderr_guard + +_FORCE_ACTIVE = patch("vibe.cli.stderr_guard._is_stderr_a_tty", return_value=True) + + +@pytest.mark.skipif(sys.platform == "win32", reason="fd redirect is Unix-only") +class TestStderrGuard: + def test_fd2_redirected_to_devnull_inside_guard(self) -> None: + with _FORCE_ACTIVE: + with stderr_guard(): + devnull_stat = os.stat(os.devnull) + fd2_stat = os.fstat(2) + assert fd2_stat.st_rdev == devnull_stat.st_rdev + + def test_fd2_restored_after_guard(self) -> None: + stat_before = os.fstat(2) + with _FORCE_ACTIVE: + with stderr_guard(): + stat_inside = os.fstat(2) + assert stat_inside.st_ino != stat_before.st_ino + stat_after = os.fstat(2) + assert stat_after.st_ino == stat_before.st_ino + + def test_sys_stderr_restored_after_exit(self) -> None: + original_stderr = sys.stderr + original_dunder = sys.__stderr__ + with _FORCE_ACTIVE: + with stderr_guard(): + assert sys.__stderr__ is not original_dunder + assert sys.stderr is original_stderr + assert sys.__stderr__ is original_dunder + + def test_restores_stderr_when_render_flush_raises_value_error(self) -> None: + original_stderr = sys.stderr + original_dunder = sys.__stderr__ + stat_before = os.fstat(2) + + with _FORCE_ACTIVE: + with stderr_guard(): + assert sys.__stderr__ is not None + sys.__stderr__.close() + + stat_after = os.fstat(2) + assert stat_after.st_ino == stat_before.st_ino + assert sys.stderr is original_stderr + assert sys.__stderr__ is original_dunder + + def test_render_file_is_writable_inside_guard(self) -> None: + with _FORCE_ACTIVE: + with stderr_guard(): + assert sys.__stderr__ is not None + assert sys.__stderr__.writable() + + def test_noop_when_stderr_is_not_a_tty(self) -> None: + original_stderr = sys.stderr + with patch("vibe.cli.stderr_guard._is_stderr_a_tty", return_value=False): + with stderr_guard(): + assert sys.stderr is original_stderr + + def test_native_write_to_fd2_goes_to_devnull(self) -> None: + with _FORCE_ACTIVE: + with stderr_guard(): + # Should not raise; bytes vanish into /dev/null. + written = os.write(2, b"stray MallocStackLogging message\n") + assert written > 0 diff --git a/tests/cli/test_ui_config_and_model_picker.py b/tests/cli/test_ui_config_and_model_picker.py index dd5d347..8a38dd3 100644 --- a/tests/cli/test_ui_config_and_model_picker.py +++ b/tests/cli/test_ui_config_and_model_picker.py @@ -1,6 +1,6 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -8,7 +8,8 @@ from tests.conftest import build_test_vibe_app, build_test_vibe_config from vibe.cli.textual_ui.app import BottomApp from vibe.cli.textual_ui.widgets.config_app import ConfigApp from vibe.cli.textual_ui.widgets.model_picker import ModelPickerApp -from vibe.core.config._settings import ModelConfig +from vibe.cli.textual_ui.widgets.thinking_picker import ThinkingPickerApp +from vibe.core.config._settings import THINKING_LEVELS, ModelConfig def _make_config_with_models(): @@ -60,7 +61,8 @@ async def test_config_toggle_autocopy() -> None: await app._show_config() await pilot.pause(0.2) - # Navigate down to Auto-copy (second item) and toggle + # Navigate down to Auto-copy (third item, after Model + Thinking) and toggle + await pilot.press("down") await pilot.press("down") await pilot.press("enter") await pilot.pause(0.1) @@ -80,7 +82,8 @@ async def test_config_escape_saves_changes() -> None: await app._show_config() await pilot.pause(0.2) - # Toggle auto-copy + # Toggle auto-copy (skip Model + Thinking rows) + await pilot.press("down") await pilot.press("down") await pilot.press("enter") await pilot.pause(0.1) @@ -268,13 +271,15 @@ async def test_config_pending_changes_saved_before_model_picker() -> None: await app._show_config() await pilot.pause(0.2) - # Toggle auto-copy (second row) + # Toggle auto-copy (third row, after Model + Thinking) + await pilot.press("down") await pilot.press("down") await pilot.press("enter") await pilot.pause(0.1) # Go back up to model row and open model picker await pilot.press("up") + await pilot.press("up") with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save: await pilot.press("enter") await pilot.pause(0.3) @@ -282,3 +287,151 @@ async def test_config_pending_changes_saved_before_model_picker() -> None: mock_save.assert_called_once() changes = mock_save.call_args[0][0] assert changes["autocopy_to_clipboard"] is True + + +# --- /thinking command --- + + +@pytest.mark.asyncio +async def test_thinking_opens_thinking_picker() -> None: + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_thinking() + await pilot.pause(0.2) + + assert app._current_bottom_app == BottomApp.ThinkingPicker + assert len(app.query(ThinkingPickerApp)) == 1 + + +@pytest.mark.asyncio +async def test_thinking_picker_shows_all_levels() -> None: + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_thinking() + await pilot.pause(0.2) + + picker = app.query_one(ThinkingPickerApp) + assert picker._thinking_levels == THINKING_LEVELS + assert picker._current_thinking == "off" + + +@pytest.mark.asyncio +async def test_thinking_picker_escape_returns_to_input() -> None: + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_thinking() + await pilot.pause(0.2) + + await pilot.press("escape") + await pilot.pause(0.2) + + assert app._current_bottom_app == BottomApp.Input + assert len(app.query(ThinkingPickerApp)) == 0 + + +@pytest.mark.asyncio +async def test_thinking_picker_select_level() -> None: + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_thinking() + await pilot.pause(0.2) + + # Navigate down to "low" (second item) and select + await pilot.press("down") + with patch.object(app, "_reload_config", new=AsyncMock()): + await pilot.press("enter") + await pilot.pause(0.2) + + assert app._current_bottom_app == BottomApp.Input + assert len(app.query(ThinkingPickerApp)) == 0 + assert app.config.get_active_model().thinking == "low" + + +@pytest.mark.asyncio +async def test_thinking_picker_select_high() -> None: + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_thinking() + await pilot.pause(0.2) + + # Navigate to "high" (4th item = 3 downs from "off") + await pilot.press("down") + await pilot.press("down") + await pilot.press("down") + with patch.object(app, "_reload_config", new=AsyncMock()): + await pilot.press("enter") + await pilot.pause(0.2) + + assert app.config.get_active_model().thinking == "high" + + +# --- config -> thinking picker flow --- + + +@pytest.mark.asyncio +async def test_config_thinking_entry_opens_thinking_picker() -> None: + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_config() + await pilot.pause(0.2) + + # Thinking row is the second item (after Model). Navigate down and press enter. + await pilot.press("down") + await pilot.press("enter") + await pilot.pause(0.3) + + assert app._current_bottom_app == BottomApp.ThinkingPicker + assert len(app.query(ThinkingPickerApp)) == 1 + assert len(app.query(ConfigApp)) == 0 + + +@pytest.mark.asyncio +async def test_config_to_thinking_picker_escape_returns_to_input() -> None: + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_config() + await pilot.pause(0.2) + + # Open thinking picker from config + await pilot.press("down") + await pilot.press("enter") + await pilot.pause(0.3) + + # Escape thinking picker + await pilot.press("escape") + await pilot.pause(0.2) + + assert app._current_bottom_app == BottomApp.Input + assert len(app.query(ThinkingPickerApp)) == 0 + assert len(app.query(ConfigApp)) == 0 + + +@pytest.mark.asyncio +async def test_config_to_thinking_picker_select_returns_to_input() -> None: + app = build_test_vibe_app(config=_make_config_with_models()) + async with app.run_test() as pilot: + await pilot.pause(0.1) + await app._show_config() + await pilot.pause(0.2) + + # Open thinking picker from config + await pilot.press("down") + await pilot.press("enter") + await pilot.pause(0.3) + + # Select "medium" (3rd item = 2 downs from "off") + await pilot.press("down") + await pilot.press("down") + with patch.object(app, "_reload_config", new=AsyncMock()): + await pilot.press("enter") + await pilot.pause(0.2) + + assert app._current_bottom_app == BottomApp.Input + assert app.config.get_active_model().thinking == "medium" diff --git a/tests/cli/test_ui_teleport_availability.py b/tests/cli/test_ui_teleport_availability.py new file mode 100644 index 0000000..45bb0d2 --- /dev/null +++ b/tests/cli/test_ui_teleport_availability.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import time +from unittest.mock import AsyncMock, patch + +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 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 + + +def _chat_plan_gateway(*, prompt_switching_to_pro_plan: bool) -> FakeWhoAmIGateway: + return FakeWhoAmIGateway( + WhoAmIResponse( + plan_type=WhoAmIPlanType.CHAT, + plan_name="INDIVIDUAL", + prompt_switching_to_pro_plan=prompt_switching_to_pro_plan, + ) + ) + + +def _vibe_code_enabled_config() -> VibeConfig: + return build_test_vibe_config(vibe_code_enabled=True) + + +async def _wait_until(pause, predicate, timeout: float = 2.0) -> None: + start = time.monotonic() + while time.monotonic() - start < timeout: + if predicate(): + return + await pause(0.02) + raise AssertionError("Condition was not met within the timeout") + + +@pytest.mark.asyncio +async def test_teleport_command_visible_for_paid_chat_users() -> None: + app = build_test_vibe_app( + config=_vibe_code_enabled_config(), + plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=False), + ) + + async with app.run_test() as pilot: + await _wait_until( + pilot.pause, + lambda: app.commands.get_command_name("/teleport") == "teleport", + ) + + assert app.commands.get_command_name("/teleport") == "teleport" + assert "/teleport" in app.commands.get_help_text() + input_widget = app.query_one(ChatInputContainer).input_widget + assert input_widget is not None + assert "&" in input_widget.mode_characters + + +@pytest.mark.asyncio +async def test_teleport_command_hidden_when_current_key_is_not_eligible() -> None: + app = build_test_vibe_app( + config=_vibe_code_enabled_config(), + plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True), + ) + + async with app.run_test() as pilot: + await pilot.pause(0.2) + + assert app.commands.get_command_name("/teleport") is None + assert "/teleport" not in app.commands.get_help_text() + input_widget = app.query_one(ChatInputContainer).input_widget + assert input_widget is not None + assert "&" not in input_widget.mode_characters + + +@pytest.mark.asyncio +async def test_hidden_teleport_command_falls_through_as_user_text() -> None: + app = build_test_vibe_app( + config=_vibe_code_enabled_config(), + plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True), + ) + + async with app.run_test() as pilot: + await pilot.pause(0.2) + + app._handle_teleport_command = AsyncMock() + app._handle_user_message = AsyncMock() + + await app.on_chat_input_container_submitted( + ChatInputContainer.Submitted("/teleport") + ) + + app._handle_teleport_command.assert_not_awaited() + app._handle_user_message.assert_awaited_once_with("/teleport") + + +@pytest.mark.asyncio +async def test_hidden_ampersand_teleport_shortcut_falls_through_as_user_text() -> None: + app = build_test_vibe_app( + config=_vibe_code_enabled_config(), + plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True), + ) + + async with app.run_test() as pilot: + await pilot.pause(0.2) + + app._handle_teleport_command = AsyncMock() + app._handle_user_message = AsyncMock() + + await app.on_chat_input_container_submitted( + ChatInputContainer.Submitted("&continue") + ) + + app._handle_teleport_command.assert_not_awaited() + app._handle_user_message.assert_awaited_once_with("&continue") + + +@pytest.mark.asyncio +async def test_teleport_command_hides_after_switching_to_non_mistral_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "mock-openai-key") + config = build_test_vibe_config( + vibe_code_enabled=True, + providers=[ + ProviderConfig( + name="mistral", + api_base="https://api.mistral.ai/v1", + api_key_env_var="MISTRAL_API_KEY", + backend=Backend.MISTRAL, + ), + ProviderConfig( + name="openai", + api_base="https://api.openai.com/v1", + api_key_env_var="OPENAI_API_KEY", + backend=Backend.GENERIC, + ), + ], + models=[ + ModelConfig( + name="mistral-vibe-cli-latest", provider="mistral", alias="devstral" + ), + ModelConfig(name="gpt-4.1", provider="openai", alias="gpt"), + ], + active_model="devstral", + ) + app = build_test_vibe_app( + config=config, + plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=False), + ) + non_mistral_config = build_test_vibe_config( + vibe_code_enabled=True, + providers=config.providers, + models=config.models, + active_model="gpt", + ) + + async def fake_reload_with_initial_messages(*, base_config) -> None: + app.agent_loop._base_config = base_config + app.agent_loop.agent_manager.invalidate_config() + + async with app.run_test() as pilot: + await _wait_until( + pilot.pause, + lambda: app.commands.get_command_name("/teleport") == "teleport", + ) + + with ( + patch( + "vibe.cli.textual_ui.app.VibeConfig.load", + return_value=non_mistral_config, + ), + patch.object( + app.agent_loop, + "reload_with_initial_messages", + new=AsyncMock(side_effect=fake_reload_with_initial_messages), + ), + ): + await app._reload_config() + + await _wait_until( + pilot.pause, lambda: app.commands.get_command_name("/teleport") is None + ) + + assert app.commands.get_command_name("/teleport") is None + input_widget = app.query_one(ChatInputContainer).input_widget + assert input_widget is not None + assert "&" not in input_widget.mode_characters diff --git a/tests/cli/textual_ui/test_completion_popup.py b/tests/cli/textual_ui/test_completion_popup.py new file mode 100644 index 0000000..54e00dc --- /dev/null +++ b/tests/cli/textual_ui/test_completion_popup.py @@ -0,0 +1,12 @@ +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_event_handler_hooks.py b/tests/cli/textual_ui/test_event_handler_hooks.py new file mode 100644 index 0000000..4365199 --- /dev/null +++ b/tests/cli/textual_ui/test_event_handler_hooks.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +import vibe.cli.textual_ui.handlers.event_handler as event_handler_module +from vibe.cli.textual_ui.handlers.event_handler import EventHandler +from vibe.core.hooks.models import ( + HookEndEvent, + HookMessageSeverity, + HookRunEndEvent, + HookRunStartEvent, +) + + +class FakeHookRunContainer: + def __init__(self) -> None: + self.display = False + self.remove = AsyncMock() + + async def add_message(self, _widget: object) -> None: + self.display = True + + +@pytest.mark.asyncio +async def test_hook_run_end_removes_empty_container( + monkeypatch: pytest.MonkeyPatch, +) -> None: + created_containers: list[FakeHookRunContainer] = [] + + def make_container() -> FakeHookRunContainer: + container = FakeHookRunContainer() + created_containers.append(container) + return container + + monkeypatch.setattr(event_handler_module, "HookRunContainer", make_container) + + mount_callback = AsyncMock() + handler = EventHandler( + mount_callback=mount_callback, get_tools_collapsed=lambda: False + ) + + await handler.handle_event(HookRunStartEvent()) + await handler.handle_event(HookRunEndEvent()) + + assert len(created_containers) == 1 + created_containers[0].remove.assert_awaited_once() + assert handler._hook_run_container is None + + +@pytest.mark.asyncio +async def test_hook_run_end_keeps_container_with_messages( + monkeypatch: pytest.MonkeyPatch, +) -> None: + created_containers: list[FakeHookRunContainer] = [] + + def make_container() -> FakeHookRunContainer: + container = FakeHookRunContainer() + created_containers.append(container) + return container + + monkeypatch.setattr(event_handler_module, "HookRunContainer", make_container) + + mount_callback = AsyncMock() + handler = EventHandler( + mount_callback=mount_callback, get_tools_collapsed=lambda: False + ) + + await handler.handle_event(HookRunStartEvent()) + await handler.handle_event( + HookEndEvent( + hook_name="post-turn", status=HookMessageSeverity.OK, content="Hook output" + ) + ) + await handler.handle_event(HookRunEndEvent()) + + assert len(created_containers) == 1 + created_containers[0].remove.assert_not_awaited() + assert created_containers[0].display is True + assert handler._hook_run_container is None diff --git a/tests/cli/textual_ui/test_quit_confirmation.py b/tests/cli/textual_ui/test_quit_confirmation.py new file mode 100644 index 0000000..222b30d --- /dev/null +++ b/tests/cli/textual_ui/test_quit_confirmation.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import time +from unittest.mock import 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.quit_manager import QUIT_CONFIRM_DELAY, QuitManager + + +@pytest.fixture +def app() -> VibeApp: + return build_test_vibe_app() + + +@pytest.fixture +def qm() -> QuitManager: + mock_app = MagicMock() + mock_app.query_one.side_effect = Exception("not mounted") + mock_app.set_timer.return_value = MagicMock() + return QuitManager(mock_app) + + +class TestQuitManager: + def test_not_confirmed_initially(self, qm: QuitManager) -> None: + assert qm.is_confirmed("Ctrl+C") is False + assert qm.is_confirmed("Ctrl+D") is False + + def test_confirmed_within_delay(self, qm: QuitManager) -> None: + qm.request_confirmation("Ctrl+C") + assert qm.is_confirmed("Ctrl+C") is True + + def test_wrong_key_not_confirmed(self, qm: QuitManager) -> None: + qm.request_confirmation("Ctrl+C") + assert qm.is_confirmed("Ctrl+D") is False + + def test_expired_not_confirmed(self, qm: QuitManager) -> None: + qm.request_confirmation("Ctrl+C") + qm._confirm_time = time.monotonic() - QUIT_CONFIRM_DELAY - 0.1 + assert qm.is_confirmed("Ctrl+C") is False + + def test_request_resets_timer_on_key_switch(self, qm: QuitManager) -> None: + qm.request_confirmation("Ctrl+C") + qm._confirm_time = time.monotonic() - QUIT_CONFIRM_DELAY + 0.05 + qm.request_confirmation("Ctrl+D") + assert qm.is_confirmed("Ctrl+D") is True + + def test_confirm_key_property(self, qm: QuitManager) -> None: + assert qm.confirm_key is None + qm.request_confirmation("Ctrl+D") + assert qm.confirm_key == "Ctrl+D" + + def test_request_schedules_cancel_timer(self, qm: QuitManager) -> None: + qm.request_confirmation("Ctrl+D") + mock_app = qm._app + assert isinstance(mock_app, MagicMock) + mock_app.set_timer.assert_called_once_with( + QUIT_CONFIRM_DELAY, qm.cancel_confirmation + ) + + def test_request_stops_previous_timer(self, qm: QuitManager) -> None: + qm.request_confirmation("Ctrl+C") + first_timer = qm._confirm_timer + assert isinstance(first_timer, MagicMock) + qm.request_confirmation("Ctrl+D") + first_timer.stop.assert_called_once() + + def test_cancel_confirmation_resets_state(self, qm: QuitManager) -> None: + qm.request_confirmation("Ctrl+C") + qm.cancel_confirmation() + assert qm.is_confirmed("Ctrl+C") is False + assert qm.confirm_key is None + assert qm._confirm_timer is None + + def test_cancel_confirmation_noop_when_idle(self, qm: QuitManager) -> None: + qm.cancel_confirmation() + assert qm.confirm_key is None + + +class TestActionInterruptOrQuit: + def test_clears_input_when_has_value(self, app: VibeApp) -> None: + mock_container = MagicMock() + mock_container.value = "some text" + with patch.object(app, "_get_chat_input", return_value=mock_container): + app.action_interrupt_or_quit() + assert mock_container.value == "" + + def test_skips_empty_input(self, app: VibeApp) -> None: + mock_container = MagicMock() + mock_container.value = "" + with ( + patch.object(app, "_get_chat_input", return_value=mock_container), + patch.object(app, "_try_interrupt", return_value=False), + patch.object(app._quit_manager, "request_confirmation") as mock_confirm, + ): + app.action_interrupt_or_quit() + mock_confirm.assert_called_once_with("Ctrl+C") + + def test_quits_on_confirmed(self, app: VibeApp) -> None: + app._quit_manager._confirm_time = time.monotonic() + app._quit_manager._confirm_key = "Ctrl+C" + with ( + patch.object(app, "_get_chat_input", return_value=None), + patch.object(app, "_force_quit") as mock_quit, + ): + app.action_interrupt_or_quit() + mock_quit.assert_called_once() + + def test_interrupts_before_requesting_confirmation(self, app: VibeApp) -> None: + with ( + patch.object(app, "_get_chat_input", return_value=None), + patch.object(app, "_try_interrupt", return_value=True) as mock_interrupt, + patch.object(app._quit_manager, "request_confirmation") as mock_confirm, + ): + app.action_interrupt_or_quit() + mock_interrupt.assert_called_once() + mock_confirm.assert_not_called() + + def test_requests_confirmation_when_nothing_to_interrupt( + self, app: VibeApp + ) -> None: + with ( + patch.object(app, "_get_chat_input", return_value=None), + patch.object(app, "_try_interrupt", return_value=False), + patch.object(app._quit_manager, "request_confirmation") as mock_confirm, + ): + app.action_interrupt_or_quit() + mock_confirm.assert_called_once_with("Ctrl+C") + + +class TestActionDeleteRightOrQuit: + def test_deletes_right_when_input_has_value(self, app: VibeApp) -> None: + mock_input = MagicMock() + mock_container = MagicMock() + mock_container.value = "some text" + mock_container.input_widget = mock_input + with patch.object(app, "_get_chat_input", return_value=mock_container): + app.action_delete_right_or_quit() + mock_input.action_delete_right.assert_called_once() + + def test_skips_empty_input(self, app: VibeApp) -> None: + mock_container = MagicMock() + mock_container.value = "" + with ( + patch.object(app, "_get_chat_input", return_value=mock_container), + patch.object(app._quit_manager, "request_confirmation") as mock_confirm, + ): + app.action_delete_right_or_quit() + mock_confirm.assert_called_once_with("Ctrl+D") + + def test_quits_on_confirmed(self, app: VibeApp) -> None: + app._quit_manager._confirm_time = time.monotonic() + app._quit_manager._confirm_key = "Ctrl+D" + with ( + patch.object(app, "_get_chat_input", return_value=None), + patch.object(app, "_force_quit") as mock_quit, + ): + app.action_delete_right_or_quit() + mock_quit.assert_called_once() + + def test_requests_confirmation_when_no_input(self, app: VibeApp) -> None: + with ( + patch.object(app, "_get_chat_input", return_value=None), + patch.object(app._quit_manager, "request_confirmation") as mock_confirm, + ): + app.action_delete_right_or_quit() + mock_confirm.assert_called_once_with("Ctrl+D") diff --git a/tests/conftest.py b/tests/conftest.py index e2c0437..56112e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Generator from pathlib import Path import sys from typing import Any @@ -98,6 +99,7 @@ def _reset_trusted_folders_manager(config_dir: Path) -> None: trusted_folders_manager._file_path = config_dir / "trusted_folders.toml" trusted_folders_manager._trusted = [] trusted_folders_manager._untrusted = [] + trusted_folders_manager._session_trusted = [] @pytest.fixture(autouse=True) @@ -108,6 +110,31 @@ def _init_harness_files_manager(): reset_harness_files_manager() +@pytest.fixture(autouse=True) +def _scratchpad_dir( + monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory +) -> Generator[Path]: + import vibe.core.scratchpad as scratchpad_mod + + scratchpad_mod._active_scratchpads.clear() + + scratchpad_root = tmp_path_factory.mktemp("scratchpad") + _counter = 0 + + def _fake_mkdtemp(prefix: str = "") -> str: + nonlocal _counter + _counter += 1 + d = scratchpad_root / f"{prefix}{_counter}" + d.mkdir(parents=True, exist_ok=True) + return str(d) + + monkeypatch.setattr("vibe.core.scratchpad.tempfile.mkdtemp", _fake_mkdtemp) + + yield scratchpad_root + + scratchpad_mod._active_scratchpads.clear() + + @pytest.fixture(autouse=True) def _mock_api_key(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("MISTRAL_API_KEY", "mock") @@ -132,7 +159,7 @@ def _mock_update_commands(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.fixture(autouse=True) def _disable_feedback_bar(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( - "vibe.cli.textual_ui.widgets.feedback_bar.FEEDBACK_PROBABILITY", 0 + "vibe.cli.textual_ui.widgets.feedback_bar_manager.FEEDBACK_PROBABILITY", 0 ) diff --git a/tests/core/nuage/test_remote_events_source.py b/tests/core/nuage/test_remote_events_source.py index 879ff9a..e0897e2 100644 --- a/tests/core/nuage/test_remote_events_source.py +++ b/tests/core/nuage/test_remote_events_source.py @@ -2,6 +2,7 @@ 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 @@ -281,3 +282,20 @@ class TestBrokerSequenceTracking: 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 index f3cd936..ad38323 100644 --- a/tests/core/nuage/test_workflows_client.py +++ b/tests/core/nuage/test_workflows_client.py @@ -1,13 +1,14 @@ from __future__ import annotations import json -from unittest.mock import AsyncMock, patch +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: @@ -198,6 +199,47 @@ class TestStreamEvents: 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_line_iter(lines: list[str]): async def _iter(): for line in lines: diff --git a/tests/core/session/test_session_id.py b/tests/core/session/test_session_id.py new file mode 100644 index 0000000..bab3633 --- /dev/null +++ b/tests/core/session/test_session_id.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import re + +import pytest + +from vibe.core.session.session_id import extract_suffix, generate_session_id + +UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + + +class TestGenerateSessionId: + def test_uuid_shape(self) -> None: + sid = generate_session_id() + assert UUID_RE.match(sid), f"Not UUID-shaped: {sid}" + assert len(sid) == 36 + + def test_unique_ids(self) -> None: + ids = {generate_session_id() for _ in range(100)} + assert len(ids) == 100 + + def test_suffix_preserved(self) -> None: + suffix = "aabbccddeeff" + sid = generate_session_id(suffix=suffix) + assert UUID_RE.match(sid) + assert sid.endswith(suffix) + + def test_suffix_stable_across_regeneration(self) -> None: + suffix = "112233445566" + ids = [generate_session_id(suffix=suffix) for _ in range(50)] + # All share the same last segment + assert all(s.rsplit("-", 1)[-1] == suffix for s in ids) + # But first 8 chars are different (probabilistically) + prefixes = {s[:8] for s in ids} + assert len(prefixes) > 1 + + def test_default_suffix_is_12_hex(self) -> None: + sid = generate_session_id() + suffix = sid.rsplit("-", 1)[-1] + assert len(suffix) == 12 + assert re.fullmatch(r"[0-9a-f]+", suffix) + + def test_first_8_chars_are_unique_with_same_suffix(self) -> None: + suffix = "abcdef123456" + a = generate_session_id(suffix=suffix) + b = generate_session_id(suffix=suffix) + assert a[:8] != b[:8] + + +class TestExtractSuffix: + def test_extract_from_generated_id(self) -> None: + sid = generate_session_id() + suffix = extract_suffix(sid) + assert sid.endswith(suffix) + assert len(suffix) == 12 + + def test_extract_from_real_uuid(self) -> None: + uuid_str = "550e8400-e29b-41d4-a716-446655440000" + assert extract_suffix(uuid_str) == "446655440000" + + def test_extract_roundtrip(self) -> None: + original_suffix = "deadbeef1234" + sid = generate_session_id(suffix=original_suffix) + assert extract_suffix(sid) == original_suffix + + def test_extract_from_no_hyphen(self) -> None: + assert extract_suffix("abcdef") == "abcdef" + + @pytest.mark.parametrize( + "session_id", + [ + "550e8400-e29b-41d4-a716-446655440000", + "abcdef01-2345-6789-abcd-ef0123456789", + "12345678-aaaa-bbbb-cccc-aabbccddeeff", + ], + ) + def test_extract_always_returns_last_segment(self, session_id: str) -> None: + expected = session_id.rsplit("-", 1)[-1] + assert extract_suffix(session_id) == expected diff --git a/tests/core/test_config_layer.py b/tests/core/test_config_layer.py new file mode 100644 index 0000000..ebdfc96 --- /dev/null +++ b/tests/core/test_config_layer.py @@ -0,0 +1,493 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from pydantic import BaseModel, ValidationError +import pytest + +from vibe.core.config.layer import ( + ConfigLayer, + LayerImplementationError, + RawConfig, + UntrustedLayerError, +) + + +class StubLayer(ConfigLayer[BaseModel]): + """Minimal concrete layer for testing.""" + + def __init__( + self, + *, + name: str = "stub", + output_schema: type[BaseModel] | None = None, + trusted: bool = True, + data: dict[str, Any] | None = None, + ) -> None: + kwargs: dict[str, Any] = {"name": name} + if output_schema is not None: + kwargs["output_schema"] = output_schema + super().__init__(**kwargs) + self._stub_trusted = trusted + self._data = data or {} + self.read_count = 0 + + async def _check_trust(self) -> bool: + return self._stub_trusted + + async def _read_config(self) -> dict[str, Any]: + self.read_count += 1 + return dict(self._data) + + +class ObservableStubLayer(StubLayer): + """Stub that records _on_trust_changed calls.""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.trust_changes: list[tuple[bool | None, bool | None]] = [] + + async def _on_trust_changed(self, old: bool | None, new: bool | None) -> None: + self.trust_changes.append((old, new)) + + +class SampleSchema(BaseModel): + name: str + count: int = 0 + + +def test_abstract_read_config_enforced() -> None: + class IncompleteLayer(ConfigLayer[BaseModel]): + pass + + with pytest.raises(TypeError): + IncompleteLayer(name="incomplete") # type: ignore[abstract] + + +def test_repr() -> None: + layer = StubLayer(name="my-layer") + assert repr(layer) == "StubLayer(name='my-layer')" + + +@pytest.mark.asyncio +async def test_default_check_trust_returns_false() -> None: + class DefaultTrustLayer(ConfigLayer[BaseModel]): + async def _read_config(self) -> dict[str, Any]: + return {} + + layer = DefaultTrustLayer(name="default") + result = await layer.resolve_trust() + assert result is False + + +@pytest.mark.asyncio +async def test_trust_initially_none() -> None: + layer = StubLayer() + assert layer.is_trusted is None + + +@pytest.mark.asyncio +async def test_resolve_trust_trusted() -> None: + layer = StubLayer(trusted=True) + result = await layer.resolve_trust() + assert result is True + assert layer.is_trusted is True + + +@pytest.mark.asyncio +async def test_resolve_trust_untrusted() -> None: + layer = StubLayer(trusted=False) + result = await layer.resolve_trust() + assert result is False + assert layer.is_trusted is False + + +@pytest.mark.asyncio +async def test_resolve_trust_fires_on_trust_changed() -> None: + layer = ObservableStubLayer(trusted=True) + await layer.resolve_trust() + assert layer.trust_changes == [(None, True)] + + +@pytest.mark.asyncio +async def test_resolve_trust_no_callback_when_unchanged() -> None: + layer = ObservableStubLayer(trusted=True) + await layer.resolve_trust() + layer.trust_changes.clear() + await layer.resolve_trust() + assert layer.trust_changes == [] + + +@pytest.mark.asyncio +async def test_grant_trust() -> None: + layer = StubLayer(trusted=False) + await layer.grant_trust() + assert layer.is_trusted is True + + +@pytest.mark.asyncio +async def test_grant_trust_fires_callback() -> None: + layer = ObservableStubLayer(trusted=False) + await layer.grant_trust() + assert layer.trust_changes == [(None, True)] + + +@pytest.mark.asyncio +async def test_grant_trust_noop_when_already_trusted() -> None: + layer = ObservableStubLayer(trusted=True) + await layer.resolve_trust() + layer.trust_changes.clear() + await layer.grant_trust() + assert layer.trust_changes == [] + + +@pytest.mark.asyncio +async def test_revoke_trust() -> None: + layer = StubLayer(trusted=True) + await layer.resolve_trust() + await layer.revoke_trust() + assert layer.is_trusted is False + + +@pytest.mark.asyncio +async def test_revoke_trust_fires_callback() -> None: + layer = ObservableStubLayer(trusted=True) + await layer.resolve_trust() + layer.trust_changes.clear() + await layer.revoke_trust() + assert layer.trust_changes == [(True, False)] + + +@pytest.mark.asyncio +async def test_revoke_trust_clears_data() -> None: + layer = StubLayer(data={"k": "v"}) + await layer.load() + assert layer.read_count == 1 + await layer.revoke_trust() + await layer.grant_trust() + await layer.load() + assert layer.read_count == 2 + + +@pytest.mark.asyncio +async def test_on_trust_changed_failure_preserves_state() -> None: + class FailingLayer(StubLayer): + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.should_fail = True + + async def _on_trust_changed(self, old: bool | None, new: bool | None) -> None: + if self.should_fail: + raise RuntimeError("persistence failure") + + layer = FailingLayer(trusted=False) + assert layer.is_trusted is None + + with pytest.raises(LayerImplementationError, match="_on_trust_changed") as exc_info: + await layer.grant_trust() + assert isinstance(exc_info.value.__cause__, RuntimeError) + assert layer.is_trusted is None + + layer.should_fail = False + await layer.grant_trust() + assert layer.is_trusted is True + + +@pytest.mark.asyncio +async def test_check_trust_failure_wrapped() -> None: + class BrokenTrustLayer(StubLayer): + async def _check_trust(self) -> bool: + raise OSError("trust store unavailable") + + layer = BrokenTrustLayer() + with pytest.raises(LayerImplementationError, match="_check_trust") as exc_info: + await layer.resolve_trust() + assert isinstance(exc_info.value.__cause__, IOError) + + +@pytest.mark.asyncio +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"} + + +@pytest.mark.asyncio +async def test_load_auto_resolves_trust() -> None: + layer = StubLayer(trusted=True, data={"a": 1}) + assert layer.is_trusted is None + result = await layer.load() + assert layer.is_trusted is True + assert result.model_extra == {"a": 1} + + +@pytest.mark.asyncio +async def test_load_caches_result() -> None: + layer = StubLayer() + await layer.load() + await layer.load() + await layer.load() + assert layer.read_count == 1 + + +@pytest.mark.asyncio +async def test_load_force_bypasses_cache() -> None: + layer = StubLayer() + await layer.load() + assert layer.read_count == 1 + await layer.load(force=True) + assert layer.read_count == 2 + + +@pytest.mark.asyncio +async def test_load_untrusted_raises() -> None: + layer = StubLayer(trusted=False) + with pytest.raises(UntrustedLayerError, match="stub"): + await layer.load() + + +@pytest.mark.asyncio +async def test_load_after_grant_trust() -> None: + layer = StubLayer(trusted=False) + await layer.grant_trust() + result = await layer.load() + assert isinstance(result, RawConfig) + + +@pytest.mark.asyncio +async def test_load_after_revoke_trust_raises() -> None: + layer = StubLayer(trusted=True) + await layer.load() + await layer.revoke_trust() + with pytest.raises(UntrustedLayerError): + await layer.load() + + +@pytest.mark.asyncio +async def test_invalidate_cache_causes_reload() -> None: + layer = StubLayer() + await layer.load() + assert layer.read_count == 1 + await layer.invalidate_cache() + await layer.load() + assert layer.read_count == 2 + + +@pytest.mark.asyncio +async def test_revoke_grant_cycle_refreshes_data() -> None: + layer = StubLayer(data={"v": 1}) + result1 = await layer.load() + assert result1.model_extra == {"v": 1} + + await layer.revoke_trust() + layer._data = {"v": 2} + await layer.grant_trust() + + result2 = await layer.load() + assert result2.model_extra == {"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} + + # External revocation via resolve_trust (not revoke_trust) + layer._stub_trusted = False + await layer.resolve_trust() + assert layer.is_trusted is False + + # Re-trust and update backing data while revoked + layer._stub_trusted = True + layer._data = {"v": 2} + await layer.resolve_trust() + + result2 = await layer.load() + assert result2.model_extra == {"v": 2} + + +@pytest.mark.asyncio +async def test_load_returns_deep_copy() -> None: + layer = StubLayer(data={"items": ["a", "b"]}) + result1 = await layer.load() + assert result1.model_extra is not None + result1.model_extra["items"].append("mutated") + + result2 = await layer.load() + assert result2.model_extra == {"items": ["a", "b"]} + assert layer.read_count == 1 + + +@pytest.mark.asyncio +async def test_read_config_failure_wrapped() -> None: + class BrokenReadLayer(StubLayer): + async def _read_config(self) -> dict[str, Any]: + raise OSError("config file missing") + + layer = BrokenReadLayer() + with pytest.raises(LayerImplementationError, match="_read_config") as exc_info: + await layer.load() + assert isinstance(exc_info.value.__cause__, IOError) + + +@pytest.mark.asyncio +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"} + + +@pytest.mark.asyncio +async def test_custom_schema_validates() -> None: + layer = StubLayer(output_schema=SampleSchema, data={"name": "test", "count": 3}) + result = await layer.load() + assert isinstance(result, SampleSchema) + assert result.name == "test" + assert result.count == 3 + + +@pytest.mark.asyncio +async def test_invalid_data_raises_validation_error() -> None: + layer = StubLayer(output_schema=SampleSchema, data={"count": "bad"}) + with pytest.raises(ValidationError): + await layer.load() + + +@pytest.mark.asyncio +async def test_concurrent_loads_serialize() -> None: + class SlowLayer(ConfigLayer[BaseModel]): + def __init__(self) -> None: + super().__init__(name="slow") + self.read_count = 0 + + async def _check_trust(self) -> bool: + return True + + async def _read_config(self) -> dict[str, Any]: + self.read_count += 1 + await asyncio.sleep(0.05) + return {"v": self.read_count} + + layer = SlowLayer() + results = await asyncio.gather(layer.load(), layer.load(), layer.load()) + assert layer.read_count == 1 + assert all(r == results[0] for r in results) + + +@pytest.mark.asyncio +async def test_get_fingerprint_not_implemented() -> None: + layer = StubLayer() + with pytest.raises(NotImplementedError): + await layer.get_fingerprint() + + +@pytest.mark.asyncio +async def test_apply_not_implemented() -> None: + layer = StubLayer() + with pytest.raises(NotImplementedError): + await layer.apply({"op": "set"}) + + +# Scenario: LocalUserConfigLayer + + +class UserConfigSchema(BaseModel): + active_model: str + theme: str = "dark" + + +class FakeLocalUserLayer(ConfigLayer[UserConfigSchema]): + """Simulates ~/.vibe/config.toml — always trusted, typed output.""" + + def __init__(self, data: dict[str, Any]) -> None: + super().__init__(name="user-toml", output_schema=UserConfigSchema) + self._data = data + + async def _check_trust(self) -> bool: + return True + + async def _read_config(self) -> dict[str, Any]: + return dict(self._data) + + +@pytest.mark.asyncio +async def test_scenario_local_user_layer_always_trusted() -> None: + layer = FakeLocalUserLayer({"active_model": "devstral-2", "theme": "light"}) + + assert layer.is_trusted is None + result = await layer.load() + assert layer.is_trusted is True + + assert isinstance(result, UserConfigSchema) + assert result.active_model == "devstral-2" + assert result.theme == "light" + + validated = layer.validate_output({ + "active_model": "mistral-large", + "theme": "dark", + }) + assert isinstance(validated, UserConfigSchema) + assert validated.active_model == "mistral-large" + + +# Scenario: LocalProjectConfigLayer + + +class FakeLocalProjectLayer(ConfigLayer[BaseModel]): + def __init__( + self, *, project_path: str, data: dict[str, Any], trust_store: dict[str, bool] + ) -> None: + super().__init__(name=f"project-toml:{project_path}") + self._project_path = project_path + self._data = data + self._trust_store = trust_store + + async def _check_trust(self) -> bool: + return self._trust_store.get(self._project_path, False) + + async def _on_trust_changed(self, old: bool | None, new: bool | None) -> None: + if new: + self._trust_store[self._project_path] = True + else: + self._trust_store.pop(self._project_path, None) + + async def _read_config(self) -> dict[str, Any]: + return dict(self._data) + + +@pytest.mark.asyncio +async def test_scenario_local_project_layer_trust_lifecycle() -> None: + trust_store: dict[str, bool] = {} + project_data = {"disabled_tools": ["rm"], "max_tokens": 4096} + + # 1. Fresh layer with empty trust store — load raises + layer = FakeLocalProjectLayer( + project_path="/tmp/my-project", data=project_data, trust_store=trust_store + ) + with pytest.raises(UntrustedLayerError): + await layer.load() + + # 2. Grant trust — persisted in store, load succeeds + await layer.grant_trust() + assert trust_store == {"/tmp/my-project": True} + result = await layer.load() + assert result.model_extra == project_data + + # 3. New instance with same trust store — loads directly + layer2 = FakeLocalProjectLayer( + project_path="/tmp/my-project", data=project_data, trust_store=trust_store + ) + assert layer2.is_trusted is None + result2 = await layer2.load() + assert layer2.is_trusted is True + assert result2.model_extra == project_data + + # 4. Revoke trust — removed from store, load raises + await layer2.revoke_trust() + assert "/tmp/my-project" not in trust_store + with pytest.raises(UntrustedLayerError): + await layer2.load() diff --git a/tests/core/test_config_resolution.py b/tests/core/test_config_resolution.py index 50646ee..c982dee 100644 --- a/tests/core/test_config_resolution.py +++ b/tests/core/test_config_resolution.py @@ -198,26 +198,50 @@ class TestSaveUpdates: assert result == {"tools": {"bash": {"default_timeout": 600}}} -class TestMigrateRemovesFindFromBashAllowlist: - def test_removes_find_from_config_file( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setenv("VIBE_HOME", str(tmp_path)) - config_file = tmp_path / "config.toml" - data = {"tools": {"bash": {"allowlist": ["echo", "find", "ls"]}}} +class TestSetThinking: + def test_persists_thinking_to_toml(self, config_dir: Path) -> None: + config_file = config_dir / "config.toml" + data = { + "active_model": "my-model", + "models": [ + {"name": "my-model", "provider": "mistral", "alias": "my-model"} + ], + } with config_file.open("wb") as f: tomli_w.dump(data, f) - reset_harness_files_manager() - init_harness_files_manager("user") - VibeConfig._migrate() + cfg = VibeConfig.load() + cfg.set_thinking("high") + + reloaded = VibeConfig.load() + assert reloaded.get_active_model().thinking == "high" + with config_file.open("rb") as f: + result = tomllib.load(f) + assert result["models"][0]["thinking"] == "high" + + def test_persists_thinking_for_correct_model(self, config_dir: Path) -> None: + config_file = config_dir / "config.toml" + data = { + "active_model": "model-b", + "models": [ + {"name": "model-a", "provider": "mistral", "alias": "model-a"}, + {"name": "model-b", "provider": "mistral", "alias": "model-b"}, + ], + } + with config_file.open("wb") as f: + tomli_w.dump(data, f) + + cfg = VibeConfig.load() + cfg.set_thinking("max") with config_file.open("rb") as f: result = tomllib.load(f) - assert "find" not in result["tools"]["bash"]["allowlist"] - assert result["tools"]["bash"]["allowlist"] == ["echo", "ls"] + assert result["models"][0].get("thinking") is None + assert result["models"][1]["thinking"] == "max" - def test_noop_when_find_not_present( + +class TestMigrateLeavesFindInBashAllowlist: + def test_keeps_find_in_config_file( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("VIBE_HOME", str(tmp_path)) @@ -232,7 +256,24 @@ class TestMigrateRemovesFindFromBashAllowlist: with config_file.open("rb") as f: result = tomllib.load(f) - assert result["tools"]["bash"]["allowlist"] == ["echo", "ls"] + assert result["tools"]["bash"]["allowlist"] == ["echo", "find", "ls"] + + def test_noop_when_find_already_present( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + config_file = tmp_path / "config.toml" + data = {"tools": {"bash": {"allowlist": ["echo", "find", "ls"]}}} + with config_file.open("wb") as f: + tomli_w.dump(data, f) + + reset_harness_files_manager() + init_harness_files_manager("user") + VibeConfig._migrate() + + with config_file.open("rb") as f: + result = tomllib.load(f) + assert result["tools"]["bash"]["allowlist"] == ["echo", "find", "ls"] def test_noop_when_no_bash_tools_section( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -313,7 +354,7 @@ class TestDefaultProviderConfig: class TestMistralBrowserAuthConfig: def test_provider_browser_auth_urls_are_dumped_when_set(self) -> None: cfg = build_test_vibe_config() - provider = cfg.get_provider_for_model(cfg.get_active_model()) + provider = cfg.get_active_provider() dumped = cfg.model_dump(mode="json") assert provider.browser_auth_base_url == DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL @@ -808,7 +849,7 @@ class TestGetMistralProvider: def test_returns_active_provider_when_it_is_mistral(self) -> None: cfg = build_test_vibe_config() provider = cfg.get_mistral_provider() - active = cfg.get_provider_for_model(cfg.get_active_model()) + active = cfg.get_active_provider() assert provider is active assert provider is not None assert provider.backend == Backend.MISTRAL @@ -867,3 +908,46 @@ class TestGetMistralProvider: ) provider = cfg.get_mistral_provider() assert provider is mistral_provider + + +class TestIsActiveModelMistral: + def test_returns_true_when_active_provider_is_mistral(self) -> None: + cfg = build_test_vibe_config() + assert cfg.is_active_model_mistral() is True + + def test_returns_false_when_active_provider_is_not_mistral(self) -> None: + cfg = build_test_vibe_config( + providers=[ + ProviderConfig( + name="llamacpp", + api_base="http://127.0.0.1:8080/v1", + api_key_env_var="", + ) + ], + models=[ + ModelConfig( + name="llama-local", provider="llamacpp", alias="llama-local" + ) + ], + active_model="llama-local", + ) + assert cfg.is_active_model_mistral() is False + + def test_returns_false_when_active_model_resolution_fails(self) -> None: + cfg = build_test_vibe_config( + providers=[ + ProviderConfig( + name="mistral", + api_base="https://api.mistral.ai/v1", + api_key_env_var="MISTRAL_API_KEY", + backend=Backend.MISTRAL, + ) + ], + models=[ + ModelConfig( + name="llama-local", provider="llamacpp", alias="llama-local" + ) + ], + active_model="llama-local", + ) + assert cfg.is_active_model_mistral() is False diff --git a/tests/core/test_hooks.py b/tests/core/test_hooks.py new file mode 100644 index 0000000..6e03e7f --- /dev/null +++ b/tests/core/test_hooks.py @@ -0,0 +1,504 @@ +from __future__ import annotations + +from pathlib import Path +import sys + +import pytest +import tomli_w + +from tests.conftest import build_test_agent_loop +from tests.mock.utils import mock_llm_chunk +from tests.stubs.fake_backend import FakeBackend +from vibe.core.config import VibeConfig +from vibe.core.hooks.config import ( + HookConfig, + HookConfigResult, + _load_hooks_file, + load_hooks_from_fs, +) +from vibe.core.hooks.executor import HookExecutor +from vibe.core.hooks.manager import HooksManager +from vibe.core.hooks.models import ( + HookEndEvent, + HookInvocation, + HookMessageSeverity, + HookStartEvent, + HookType, + HookUserMessage, +) +from vibe.core.types import BaseEvent + + +@pytest.fixture +def sample_invocation() -> HookInvocation: + return HookInvocation( + session_id="test-session", + transcript_path="", + cwd=str(Path.cwd()), + hook_event_name="post_agent_turn", + ) + + +@pytest.fixture +def config_hooks_disabled() -> VibeConfig: + return VibeConfig(enable_experimental_hooks=False) + + +@pytest.fixture +def config_hooks_enabled() -> VibeConfig: + return VibeConfig(enable_experimental_hooks=True) + + +def _write_hooks_toml(path: Path, hooks: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as f: + tomli_w.dump({"hooks": hooks}, f) + + +def _make_hook( + name: str = "test-hook", command: str = "echo ok", timeout: float = 30.0 +) -> HookConfig: + return HookConfig( + name=name, type=HookType.POST_AGENT_TURN, command=command, timeout=timeout + ) + + +class TestConfigLoading: + def test_load_from_global_file( + self, config_dir: Path, config_hooks_enabled: VibeConfig + ) -> None: + _write_hooks_toml( + config_dir / "hooks.toml", + [ + { + "name": "lint", + "type": HookType.POST_AGENT_TURN, + "command": "echo lint", + } + ], + ) + result = load_hooks_from_fs(config_hooks_enabled) + assert len(result.hooks) == 1 + assert result.hooks[0].name == "lint" + assert result.issues == [] + + def test_load_from_both_global_and_project( + self, + config_dir: Path, + tmp_working_directory: Path, + config_hooks_enabled: VibeConfig, + ) -> None: + _write_hooks_toml( + config_dir / "hooks.toml", + [ + { + "name": "global-hook", + "type": "post_agent_turn", + "command": "echo global", + } + ], + ) + project_vibe = tmp_working_directory / ".vibe" + _write_hooks_toml( + project_vibe / "hooks.toml", + [ + { + "name": "project-hook", + "type": "post_agent_turn", + "command": "echo project", + } + ], + ) + from vibe.core.trusted_folders import trusted_folders_manager + + trusted_folders_manager.add_trusted(tmp_working_directory) + + result = load_hooks_from_fs(config_hooks_enabled) + assert len(result.hooks) == 2 + names = {h.name for h in result.hooks} + assert names == {"global-hook", "project-hook"} + + def test_project_file_skipped_when_untrusted( + self, tmp_working_directory: Path, config_hooks_enabled: VibeConfig + ) -> None: + project_vibe = tmp_working_directory / ".vibe" + _write_hooks_toml( + project_vibe / "hooks.toml", + [ + { + "name": "sneaky-hook", + "type": "post_agent_turn", + "command": "echo sneaky", + } + ], + ) + result = load_hooks_from_fs(config_hooks_enabled) + assert not any(h.name == "sneaky-hook" for h in result.hooks) + + def test_duplicate_hook_name_detection( + self, + config_dir: Path, + tmp_working_directory: Path, + config_hooks_enabled: VibeConfig, + ) -> None: + _write_hooks_toml( + config_dir / "hooks.toml", + [{"name": "dup-hook", "type": "post_agent_turn", "command": "echo global"}], + ) + project_vibe = tmp_working_directory / ".vibe" + _write_hooks_toml( + project_vibe / "hooks.toml", + [ + { + "name": "dup-hook", + "type": "post_agent_turn", + "command": "echo project", + } + ], + ) + from vibe.core.trusted_folders import trusted_folders_manager + + trusted_folders_manager.add_trusted(tmp_working_directory) + + result = load_hooks_from_fs(config_hooks_enabled) + assert len(result.hooks) == 1 + assert any("Duplicate" in i.message for i in result.issues) + + def test_toml_parse_error_reported( + self, config_dir: Path, config_hooks_enabled: VibeConfig + ) -> None: + hooks_file = config_dir / "hooks.toml" + hooks_file.write_text("this is not valid toml [[[", encoding="utf-8") + result = load_hooks_from_fs(config_hooks_enabled) + assert result.hooks == [] + assert len(result.issues) == 1 + assert ( + "parse" in result.issues[0].message.lower() + or "Failed" in result.issues[0].message + ) + + def test_validation_error_reported( + self, config_dir: Path, config_hooks_enabled: VibeConfig + ) -> None: + _write_hooks_toml( + config_dir / "hooks.toml", + [{"name": "bad", "type": "InvalidType", "command": "echo"}], + ) + result = load_hooks_from_fs(config_hooks_enabled) + assert result.hooks == [] + assert len(result.issues) == 1 + + def test_missing_command_reported( + self, config_dir: Path, config_hooks_enabled: VibeConfig + ) -> None: + _write_hooks_toml( + config_dir / "hooks.toml", [{"name": "no-cmd", "type": "post_agent_turn"}] + ) + result = load_hooks_from_fs(config_hooks_enabled) + assert result.hooks == [] + assert len(result.issues) == 1 + + def test_empty_command_reported( + self, config_dir: Path, config_hooks_enabled: VibeConfig + ) -> None: + _write_hooks_toml( + config_dir / "hooks.toml", + [{"name": "empty-cmd", "type": "post_agent_turn", "command": " "}], + ) + result = load_hooks_from_fs(config_hooks_enabled) + assert result.hooks == [] + assert len(result.issues) == 1 + + def test_default_timeout( + self, config_dir: Path, config_hooks_enabled: VibeConfig + ) -> None: + _write_hooks_toml( + config_dir / "hooks.toml", + [{"name": "h", "type": "post_agent_turn", "command": "echo ok"}], + ) + result = load_hooks_from_fs(config_hooks_enabled) + assert result.hooks[0].timeout == 30.0 + + def test_nonexistent_file_returns_empty(self, tmp_path: Path) -> None: + result = _load_hooks_file(tmp_path / "missing.toml") + assert result.hooks == [] + assert result.issues == [] + + def test_hooks_disabled_returns_empty( + self, config_dir: Path, config_hooks_disabled: VibeConfig + ) -> None: + _write_hooks_toml( + config_dir / "hooks.toml", + [ + { + "name": "lint", + "type": HookType.POST_AGENT_TURN, + "command": "echo lint", + } + ], + ) + result = load_hooks_from_fs(config_hooks_disabled) + assert result.hooks == [] + assert result.issues == [] + + +class TestHookExecutor: + @pytest.mark.asyncio + async def test_exit_0_success(self, sample_invocation: HookInvocation) -> None: + hook = _make_hook(command="echo success") + result = await HookExecutor().run(hook, sample_invocation) + assert result.exit_code == 0 + assert result.stdout == "success" + assert not result.timed_out + + @pytest.mark.asyncio + async def test_exit_2_retry(self, sample_invocation: HookInvocation) -> None: + hook = _make_hook(command="echo 'fix this'; exit 2") + result = await HookExecutor().run(hook, sample_invocation) + assert result.exit_code == 2 + assert "fix this" in result.stdout + assert not result.timed_out + + @pytest.mark.asyncio + async def test_other_exit_code(self, sample_invocation: HookInvocation) -> None: + hook = _make_hook(command="echo 'oops'; exit 1") + result = await HookExecutor().run(hook, sample_invocation) + assert result.exit_code == 1 + assert "oops" in result.stdout + + @pytest.mark.asyncio + async def test_timeout(self, sample_invocation: HookInvocation) -> None: + hook = _make_hook(command="sleep 60", timeout=0.5) + result = await HookExecutor().run(hook, sample_invocation) + assert result.timed_out + assert result.exit_code is None + + @pytest.mark.asyncio + async def test_stderr_captured_separately( + self, sample_invocation: HookInvocation + ) -> None: + hook = _make_hook(command="echo out; echo err >&2") + result = await HookExecutor().run(hook, sample_invocation) + assert result.exit_code == 0 + assert result.stdout == "out" + assert result.stderr == "err" + + @pytest.mark.asyncio + async def test_stdin_json_received(self, sample_invocation: HookInvocation) -> None: + hook = _make_hook( + command=f"{sys.executable} -c \"import sys,json; d=json.load(sys.stdin); print(d['session_id'])\"" + ) + result = await HookExecutor().run(hook, sample_invocation) + assert result.exit_code == 0 + assert result.stdout == "test-session" + + +class TestHooksManager: + @pytest.mark.asyncio + async def test_exit_0_emits_start_and_end(self) -> None: + handler = HooksManager([_make_hook(command="echo ok")]) + from vibe.core.config import SessionLoggingConfig + from vibe.core.session.session_logger import SessionLogger + + logger = SessionLogger(SessionLoggingConfig(enabled=False), "test-id") + events: list[BaseEvent | HookUserMessage] = [] + async for ev in handler.run(HookType.POST_AGENT_TURN, "sess", logger): + events.append(ev) + + event_types = [type(e).__name__ for e in events] + assert "HookStartEvent" in event_types + assert "HookEndEvent" in event_types + # Exit 0 with no retry = no HookUserMessage + assert not any(isinstance(e, HookUserMessage) for e in events) + + @pytest.mark.asyncio + async def test_exit_2_emits_retry_message(self) -> None: + handler = HooksManager([_make_hook(command="echo 'fix it'; exit 2")]) + from vibe.core.config import SessionLoggingConfig + from vibe.core.session.session_logger import SessionLogger + + logger = SessionLogger(SessionLoggingConfig(enabled=False), "test-id") + events: list[BaseEvent | HookUserMessage] = [] + async for ev in handler.run(HookType.POST_AGENT_TURN, "sess", logger): + events.append(ev) + + retry_msgs = [e for e in events if isinstance(e, HookUserMessage)] + assert len(retry_msgs) == 1 + assert "fix it" in retry_msgs[0].content + + # Display message should be generic, not the stdout + end_msgs = [ + e for e in events if isinstance(e, HookEndEvent) and e.content is not None + ] + assert any("retrying" in m.content.lower() for m in end_msgs if m.content) + assert not any("fix it" in (m.content or "") for m in end_msgs) + + @pytest.mark.asyncio + async def test_exit_2_without_output_emits_warning(self) -> None: + handler = HooksManager([_make_hook(command="exit 2")]) + from vibe.core.config import SessionLoggingConfig + from vibe.core.session.session_logger import SessionLogger + + logger = SessionLogger(SessionLoggingConfig(enabled=False), "test-id") + events: list[BaseEvent | HookUserMessage] = [] + async for ev in handler.run(HookType.POST_AGENT_TURN, "sess", logger): + events.append(ev) + + end_msgs = [e for e in events if isinstance(e, HookEndEvent)] + assert len(end_msgs) == 1 + assert end_msgs[0].content == "Exited with code 2" + + @pytest.mark.asyncio + async def test_max_retry_limit(self) -> None: + handler = HooksManager([_make_hook(command="echo retry; exit 2")]) + from vibe.core.config import SessionLoggingConfig + from vibe.core.session.session_logger import SessionLogger + + logger = SessionLogger(SessionLoggingConfig(enabled=False), "test-id") + + # Run 3 times (should get retry each time) + for _ in range(3): + events = [ + ev async for ev in handler.run(HookType.POST_AGENT_TURN, "sess", logger) + ] + assert any(isinstance(e, HookUserMessage) for e in events) + + # 4th time: max exceeded, no retry + events = [ + ev async for ev in handler.run(HookType.POST_AGENT_TURN, "sess", logger) + ] + assert not any(isinstance(e, HookUserMessage) for e in events) + # Should have error message about max retries + error_events = [ + e + for e in events + if isinstance(e, HookEndEvent) + and e.content + and "exhausted" in e.content.lower() + ] + assert len(error_events) == 1 + + @pytest.mark.asyncio + async def test_warning_on_nonzero_exit(self) -> None: + handler = HooksManager([_make_hook(command="echo warn; exit 1")]) + from vibe.core.config import SessionLoggingConfig + from vibe.core.session.session_logger import SessionLogger + + logger = SessionLogger(SessionLoggingConfig(enabled=False), "test-id") + events = [ + ev async for ev in handler.run(HookType.POST_AGENT_TURN, "sess", logger) + ] + + warnings = [ + e + for e in events + if isinstance(e, HookEndEvent) and e.status == HookMessageSeverity.WARNING + ] + assert len(warnings) == 1 + assert warnings[0].content and "warn" in warnings[0].content + + @pytest.mark.asyncio + async def test_warning_falls_back_to_stderr(self) -> None: + hook = _make_hook(command="echo problem >&2; exit 1") + handler = HooksManager([hook]) + from vibe.core.config import SessionLoggingConfig + from vibe.core.session.session_logger import SessionLogger + + logger = SessionLogger(SessionLoggingConfig(enabled=False), "test-id") + events = [ + ev async for ev in handler.run(HookType.POST_AGENT_TURN, "sess", logger) + ] + + warnings = [ + e + for e in events + if isinstance(e, HookEndEvent) and e.status == HookMessageSeverity.WARNING + ] + assert len(warnings) == 1 + assert warnings[0].content and "problem" in warnings[0].content + + @pytest.mark.asyncio + async def test_timeout_emits_warning(self) -> None: + handler = HooksManager([_make_hook(command="sleep 60", timeout=0.5)]) + from vibe.core.config import SessionLoggingConfig + from vibe.core.session.session_logger import SessionLogger + + logger = SessionLogger(SessionLoggingConfig(enabled=False), "test-id") + events = [ + ev async for ev in handler.run(HookType.POST_AGENT_TURN, "sess", logger) + ] + + warnings = [ + e + for e in events + if isinstance(e, HookEndEvent) and e.status == HookMessageSeverity.WARNING + ] + assert len(warnings) == 1 + assert warnings[0].content and "Timed out" in warnings[0].content + + +class TestAgentLoopIntegration: + @pytest.mark.asyncio + async def test_hooks_run_after_turn(self) -> None: + backend = FakeBackend(mock_llm_chunk(content="Hello!")) + hooks = [_make_hook(name="post-lint", command="echo ok")] + agent_loop = build_test_agent_loop( + backend=backend, hook_config_result=HookConfigResult(hooks=hooks, issues=[]) + ) + + events = [ev async for ev in agent_loop.act("hi")] + event_types = [type(e).__name__ for e in events] + assert "HookStartEvent" in event_types + assert "HookEndEvent" in event_types + + @pytest.mark.asyncio + async def test_hook_retry_reinjects_message(self) -> None: + # First call: LLM responds. Hook requests retry with "fix this". + # Second call (after retry injection): LLM responds again. Hook exits 0. + backend = FakeBackend([ + [mock_llm_chunk(content="first response")], + [mock_llm_chunk(content="second response")], + ]) + + # Create a script that exits 2 on first call, 0 on subsequent + counter_file = Path.cwd() / ".hook_counter" + script = ( + f'{sys.executable} -c "' + f"from pathlib import Path; " + f"p = Path({str(counter_file)!r}); " + f"c = int(p.read_text()) if p.exists() else 0; " + f"p.write_text(str(c + 1)); " + f"import sys; " + f"print('fix this'); " + f"sys.exit(2 if c == 0 else 0)" + f'"' + ) + hooks = [_make_hook(name="retry-hook", command=script)] + agent_loop = build_test_agent_loop( + backend=backend, hook_config_result=HookConfigResult(hooks=hooks, issues=[]) + ) + + events = [ev async for ev in agent_loop.act("hi")] + + # Should have two assistant events (two LLM turns) + from vibe.core.types import AssistantEvent + + assistant_events = [e for e in events if isinstance(e, AssistantEvent)] + assert len(assistant_events) == 2 + + # Check that a retry user message was injected + user_messages = [ + m for m in agent_loop.messages if m.role.value == "user" and m.injected + ] + assert any("fix this" in (m.content or "") for m in user_messages) + + @pytest.mark.asyncio + async def test_no_hooks_no_events(self) -> None: + backend = FakeBackend(mock_llm_chunk(content="Hello!")) + agent_loop = build_test_agent_loop(backend=backend) + + events = [ev async for ev in agent_loop.act("hi")] + hook_events = [ + e for e in events if isinstance(e, (HookStartEvent, HookEndEvent)) + ] + assert hook_events == [] diff --git a/tests/core/test_remote_agent_loop.py b/tests/core/test_remote_agent_loop.py index 6e5af8e..1f4ef8a 100644 --- a/tests/core/test_remote_agent_loop.py +++ b/tests/core/test_remote_agent_loop.py @@ -1,6 +1,7 @@ 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 ( @@ -244,6 +245,28 @@ def test_ask_user_question_tool_emits_assistant_question() -> None: 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( @@ -765,5 +788,152 @@ def test_steer_input_events_are_suppressed() -> 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_scratchpad.py b/tests/core/test_scratchpad.py new file mode 100644 index 0000000..2f7c437 --- /dev/null +++ b/tests/core/test_scratchpad.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from vibe.core.scratchpad import get_scratchpad_dir, init_scratchpad, is_scratchpad_path + + +class TestInitScratchpad: + def test_creates_directory(self): + result = init_scratchpad("test-session") + assert result is not None + assert result.is_dir() + + def test_idempotent_same_session(self): + first = init_scratchpad("session-1") + second = init_scratchpad("session-1") + assert first == second + + def test_different_sessions_get_different_dirs(self): + first = init_scratchpad("session-1") + second = init_scratchpad("session-2") + assert first != second + + def test_session_id_in_dir_name(self): + result = init_scratchpad("abcdef123456") + assert result is not None + assert "abcdef12" in result.name + + def test_sets_module_state(self): + init_scratchpad("test-session") + assert get_scratchpad_dir("test-session") is not None + + +class TestGetScratchpadDir: + def test_none_for_unknown_session(self): + assert get_scratchpad_dir("nonexistent") is None + + def test_returns_path_after_init(self): + path = init_scratchpad("test-session") + assert get_scratchpad_dir("test-session") == path + + +class TestIsScratchpadPath: + def test_false_when_not_initialized(self): + assert not is_scratchpad_path("/tmp/anything") + + def test_true_for_file_inside(self): + sp = init_scratchpad("test-session") + assert sp is not None + assert is_scratchpad_path(str(sp / "file.txt")) + + def test_true_for_nested_file(self): + sp = init_scratchpad("test-session") + assert sp is not None + assert is_scratchpad_path(str(sp / "subdir" / "file.txt")) + + def test_true_for_dir_itself(self): + sp = init_scratchpad("test-session") + assert sp is not None + assert is_scratchpad_path(str(sp)) + + def test_true_across_sessions(self): + sp1 = init_scratchpad("session-1") + sp2 = init_scratchpad("session-2") + assert sp1 is not None and sp2 is not None + assert is_scratchpad_path(str(sp1 / "file.txt")) + assert is_scratchpad_path(str(sp2 / "file.txt")) + + def test_false_for_outside_path(self): + init_scratchpad("test-session") + assert not is_scratchpad_path("/etc/passwd") + + def test_false_for_traversal_attack(self): + sp = init_scratchpad("test-session") + assert sp is not None + traversal = str(sp / ".." / ".." / ".." / "etc" / "passwd") + assert not is_scratchpad_path(traversal) + + def test_false_for_sibling_directory(self): + sp = init_scratchpad("test-session") + assert sp is not None + sibling = str(sp.parent / "other-dir" / "file.txt") + assert not is_scratchpad_path(sibling) diff --git a/tests/core/test_telemetry_send.py b/tests/core/test_telemetry_send.py index 8395b0a..3b3f1d2 100644 --- a/tests/core/test_telemetry_send.py +++ b/tests/core/test_telemetry_send.py @@ -10,7 +10,12 @@ from tests.conftest import build_test_vibe_config from tests.stubs.fake_tool import FakeTool, FakeToolArgs from vibe.core.agent_loop import ToolDecision, ToolExecutionResponse from vibe.core.llm.format import ResolvedToolCall +from vibe.core.telemetry.build_metadata import ( + build_base_metadata, + build_request_metadata, +) from vibe.core.telemetry.send import TelemetryClient +from vibe.core.telemetry.types import EntrypointMetadata, TelemetryRequestMetadata from vibe.core.tools.base import BaseTool, ToolPermission from vibe.core.types import Backend from vibe.core.utils import get_user_agent @@ -55,9 +60,7 @@ class TestTelemetryClient: self, monkeypatch: pytest.MonkeyPatch ) -> None: config = build_test_vibe_config(enable_telemetry=True) - env_key = config.get_provider_for_model( - config.get_active_model() - ).api_key_env_var + env_key = config.get_active_provider().api_key_env_var monkeypatch.delenv(env_key, raising=False) client = TelemetryClient(config_getter=lambda: config) assert client._get_mistral_api_key() is None @@ -76,9 +79,7 @@ class TestTelemetryClient: TelemetryClient, "send_telemetry_event", _original_send_telemetry_event ) config = build_test_vibe_config(enable_telemetry=False) - env_key = config.get_provider_for_model( - config.get_active_model() - ).api_key_env_var + env_key = config.get_active_provider().api_key_env_var monkeypatch.setenv(env_key, "sk-test") client = TelemetryClient(config_getter=lambda: config) client._client = MagicMock() @@ -97,9 +98,7 @@ class TestTelemetryClient: TelemetryClient, "send_telemetry_event", _original_send_telemetry_event ) config = build_test_vibe_config(enable_telemetry=True) - env_key = config.get_provider_for_model( - config.get_active_model() - ).api_key_env_var + env_key = config.get_active_provider().api_key_env_var monkeypatch.setenv(env_key, "sk-test") client = TelemetryClient(config_getter=lambda: config) mock_post = AsyncMock(return_value=MagicMock(status_code=204)) @@ -150,6 +149,25 @@ class TestTelemetryClient: assert properties["model"] == "mistral-large" assert properties["nb_files_created"] == 0 assert properties["nb_files_modified"] == 0 + assert properties["message_id"] is None + + def test_send_tool_call_finished_with_message_id( + self, telemetry_events: list[dict[str, Any]] + ) -> None: + config = build_test_vibe_config(enable_telemetry=True) + client = TelemetryClient(config_getter=lambda: config) + tool_call = _make_resolved_tool_call("todo", {}) + + client.send_tool_call_finished( + tool_call=tool_call, + status="success", + decision=None, + agent_profile_name="default", + model="mistral-large", + message_id="msg-123", + ) + + assert telemetry_events[0]["properties"]["message_id"] == "msg-123" def test_send_tool_call_finished_nb_files_created_write_file_new( self, telemetry_events: list[dict[str, Any]] @@ -237,10 +255,21 @@ class TestTelemetryClient: config = build_test_vibe_config(enable_telemetry=True) client = TelemetryClient(config_getter=lambda: config) - client.send_auto_compact_triggered() + client.send_auto_compact_triggered( + nb_context_tokens_before=123, + nb_context_tokens_after=45, + auto_compact_threshold=100, + status="success", + ) assert len(telemetry_events) == 1 assert telemetry_events[0]["event_name"] == "vibe.auto_compact_triggered" + assert telemetry_events[0]["properties"] == { + "nb_context_tokens_before": 123, + "nb_context_tokens_after": 45, + "auto_compact_threshold": 100, + "status": "success", + } def test_send_slash_command_used_payload( self, telemetry_events: list[dict[str, Any]] @@ -289,8 +318,55 @@ class TestTelemetryClient: assert properties["terminal_emulator"] == "vscode" assert "version" in properties + def test_build_base_metadata_includes_entrypoint_and_session(self) -> None: + metadata = build_base_metadata( + entrypoint_metadata=EntrypointMetadata( + agent_entrypoint="cli", + agent_version="1.0.0", + client_name="vibe_cli", + client_version="1.0.0", + ), + session_id="session-123", + parent_session_id="parent-session-456", + ) + + assert metadata == { + "agent_entrypoint": "cli", + "agent_version": "1.0.0", + "client_name": "vibe_cli", + "client_version": "1.0.0", + "session_id": "session-123", + "parent_session_id": "parent-session-456", + } + + def test_build_request_metadata_includes_all_telemetry_metadata(self) -> None: + metadata = build_request_metadata( + entrypoint_metadata=EntrypointMetadata( + agent_entrypoint="cli", + agent_version="1.0.0", + client_name="vibe_cli", + client_version="1.0.0", + ), + session_id="session-123", + parent_session_id="parent-session-456", + call_type="secondary_call", + message_id="message-456", + ) + + assert metadata == TelemetryRequestMetadata( + agent_entrypoint="cli", + agent_version="1.0.0", + client_name="vibe_cli", + client_version="1.0.0", + session_id="session-123", + parent_session_id="parent-session-456", + call_source="vibe_code", + call_type="secondary_call", + message_id="message-456", + ) + @pytest.mark.asyncio - async def test_session_id_added_when_getter_provided( + async def test_parent_session_id_added_when_getter_provided( self, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr( @@ -301,6 +377,46 @@ class TestTelemetryClient: config.get_active_model() ).api_key_env_var monkeypatch.setenv(env_key, "sk-test") + client = TelemetryClient( + config_getter=lambda: config, + session_id_getter=lambda: "session-123", + parent_session_id_getter=lambda: "parent-session-456", + ) + mock_post = AsyncMock(return_value=MagicMock(status_code=204)) + client._client = MagicMock() + client._client.post = mock_post + client._client.aclose = AsyncMock() + + client.send_telemetry_event("vibe.test_event", {"key": "value"}) + await client.aclose() + + mock_post.assert_called_once_with( + "https://api.mistral.ai/v1/datalake/events", + json={ + "event": "vibe.test_event", + "properties": { + "session_id": "session-123", + "parent_session_id": "parent-session-456", + "key": "value", + }, + }, + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer sk-test", + "User-Agent": get_user_agent(Backend.MISTRAL), + }, + ) + + @pytest.mark.asyncio + async def test_session_id_added_when_getter_provided( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + TelemetryClient, "send_telemetry_event", _original_send_telemetry_event + ) + config = build_test_vibe_config(enable_telemetry=True) + env_key = config.get_active_provider().api_key_env_var + monkeypatch.setenv(env_key, "sk-test") session_id = "test-session-uuid" client = TelemetryClient( config_getter=lambda: config, session_id_getter=lambda: session_id @@ -334,9 +450,7 @@ class TestTelemetryClient: TelemetryClient, "send_telemetry_event", _original_send_telemetry_event ) config = build_test_vibe_config(enable_telemetry=True) - env_key = config.get_provider_for_model( - config.get_active_model() - ).api_key_env_var + env_key = config.get_active_provider().api_key_env_var monkeypatch.setenv(env_key, "sk-test") client = TelemetryClient(config_getter=lambda: config) mock_post = AsyncMock(return_value=MagicMock(status_code=204)) @@ -365,9 +479,7 @@ class TestTelemetryClient: TelemetryClient, "send_telemetry_event", _original_send_telemetry_event ) config = build_test_vibe_config(enable_telemetry=True) - env_key = config.get_provider_for_model( - config.get_active_model() - ).api_key_env_var + env_key = config.get_active_provider().api_key_env_var monkeypatch.setenv(env_key, "sk-test") current_id = "first-session-id" client = TelemetryClient( @@ -389,6 +501,40 @@ class TestTelemetryClient: calls[1].kwargs["json"]["properties"]["session_id"] == "second-session-id" ) + def test_send_auto_compact_triggered_overrides_session_metadata( + self, telemetry_events: list[dict[str, Any]] + ) -> None: + config = build_test_vibe_config(enable_telemetry=True) + client = TelemetryClient( + config_getter=lambda: config, + session_id_getter=lambda: "new-session-id", + parent_session_id_getter=lambda: "new-parent-session-id", + ) + + client.send_auto_compact_triggered( + nb_context_tokens_before=123, + nb_context_tokens_after=45, + auto_compact_threshold=100, + status="success", + session_id="original-session-id", + parent_session_id=None, + ) + + assert len(telemetry_events) == 1 + properties = telemetry_events[0]["properties"] + assert properties["session_id"] == "original-session-id" + assert properties["parent_session_id"] is None + + def test_send_ready_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_ready(init_duration_ms=1240) + + assert len(telemetry_events) == 1 + assert telemetry_events[0]["event_name"] == "vibe.ready" + assert telemetry_events[0]["properties"]["init_duration_ms"] == 1240 + def test_send_request_sent_payload( self, telemetry_events: list[dict[str, Any]] ) -> None: @@ -400,6 +546,7 @@ class TestTelemetryClient: nb_context_chars=1234, nb_context_messages=5, nb_prompt_chars=42, + call_type="main_call", ) assert len(telemetry_events) == 1 @@ -409,6 +556,9 @@ class TestTelemetryClient: assert properties["nb_context_chars"] == 1234 assert properties["nb_context_messages"] == 5 assert properties["nb_prompt_chars"] == 42 + assert properties["call_source"] == "vibe_code" + assert properties["call_type"] == "main_call" + assert properties["message_id"] is None def test_send_user_rating_feedback_payload( self, telemetry_events: list[dict[str, Any]] @@ -522,9 +672,7 @@ class TestTelemetryClient: self, monkeypatch: pytest.MonkeyPatch ) -> None: config = build_test_vibe_config(enable_telemetry=True) - env_key = config.get_provider_for_model( - config.get_active_model() - ).api_key_env_var + env_key = config.get_active_provider().api_key_env_var monkeypatch.delenv(env_key, raising=False) client = TelemetryClient(config_getter=lambda: config) diff --git a/tests/core/test_teleport_nuage.py b/tests/core/test_teleport_nuage.py index 8963125..4bd5bb3 100644 --- a/tests/core/test_teleport_nuage.py +++ b/tests/core/test_teleport_nuage.py @@ -134,7 +134,9 @@ class TestNuageClientStartWorkflow: mock_client.post = AsyncMock(return_value=mock_response) params = WorkflowParams(prompt="test", integrations=WorkflowIntegrations()) - with pytest.raises(ServiceTeleportError, match="Nuage workflow trigger failed"): + with pytest.raises( + ServiceTeleportError, match="Vibe Code workflow trigger failed" + ): await nuage.start_workflow(params) diff --git a/tests/core/test_teleport_service.py b/tests/core/test_teleport_service.py index 6323a53..e6cd8dc 100644 --- a/tests/core/test_teleport_service.py +++ b/tests/core/test_teleport_service.py @@ -45,9 +45,9 @@ class TestTeleportServiceCompressDiff: mock_session_logger = MagicMock() return TeleportService( session_logger=mock_session_logger, - nuage_base_url="https://api.example.com", - nuage_workflow_id="workflow-id", - nuage_api_key="api-key", + vibe_code_base_url="https://api.example.com", + vibe_code_workflow_id="workflow-id", + vibe_code_api_key="api-key", workdir=tmp_path, ) @@ -75,9 +75,9 @@ class TestTeleportServiceBuildGitHubParams: mock_session_logger = MagicMock() return TeleportService( session_logger=mock_session_logger, - nuage_base_url="https://api.example.com", - nuage_workflow_id="workflow-id", - nuage_api_key="api-key", + vibe_code_base_url="https://api.example.com", + vibe_code_workflow_id="workflow-id", + vibe_code_api_key="api-key", workdir=tmp_path, ) @@ -116,9 +116,9 @@ class TestTeleportServiceValidateConfig: mock_session_logger = MagicMock() service = TeleportService( session_logger=mock_session_logger, - nuage_base_url="https://api.example.com", - nuage_workflow_id="workflow-id", - nuage_api_key="", + vibe_code_base_url="https://api.example.com", + vibe_code_workflow_id="workflow-id", + vibe_code_api_key="", workdir=tmp_path, ) with pytest.raises(ServiceTeleportError, match="MISTRAL_API_KEY not set"): @@ -128,9 +128,9 @@ class TestTeleportServiceValidateConfig: mock_session_logger = MagicMock() service = TeleportService( session_logger=mock_session_logger, - nuage_base_url="https://api.example.com", - nuage_workflow_id="workflow-id", - nuage_api_key="valid-key", + vibe_code_base_url="https://api.example.com", + vibe_code_workflow_id="workflow-id", + vibe_code_api_key="valid-key", workdir=tmp_path, ) service._validate_config() @@ -138,12 +138,12 @@ class TestTeleportServiceValidateConfig: def test_uses_custom_env_var_name_in_error(self, tmp_path: Path) -> None: mock_session_logger = MagicMock() mock_config = MagicMock() - mock_config.nuage_api_key_env_var = "CUSTOM_API_KEY" + mock_config.vibe_code_api_key_env_var = "CUSTOM_API_KEY" service = TeleportService( session_logger=mock_session_logger, - nuage_base_url="https://api.example.com", - nuage_workflow_id="workflow-id", - nuage_api_key="", + vibe_code_base_url="https://api.example.com", + vibe_code_workflow_id="workflow-id", + vibe_code_api_key="", workdir=tmp_path, vibe_config=mock_config, ) @@ -157,9 +157,9 @@ class TestTeleportServiceCheckSupported: mock_session_logger = MagicMock() return TeleportService( session_logger=mock_session_logger, - nuage_base_url="https://api.example.com", - nuage_workflow_id="workflow-id", - nuage_api_key="api-key", + vibe_code_base_url="https://api.example.com", + vibe_code_workflow_id="workflow-id", + vibe_code_api_key="api-key", workdir=tmp_path, ) @@ -199,9 +199,9 @@ class TestTeleportServiceIsSupported: mock_session_logger = MagicMock() return TeleportService( session_logger=mock_session_logger, - nuage_base_url="https://api.example.com", - nuage_workflow_id="workflow-id", - nuage_api_key="api-key", + vibe_code_base_url="https://api.example.com", + vibe_code_workflow_id="workflow-id", + vibe_code_api_key="api-key", workdir=tmp_path, ) @@ -222,9 +222,9 @@ class TestTeleportServiceExecute: mock_session_logger = MagicMock() service = TeleportService( session_logger=mock_session_logger, - nuage_base_url="https://api.example.com", - nuage_workflow_id="workflow-id", - nuage_api_key="api-key", + vibe_code_base_url="https://api.example.com", + vibe_code_workflow_id="workflow-id", + vibe_code_api_key="api-key", workdir=tmp_path, ) service._git.fetch = AsyncMock() @@ -268,7 +268,7 @@ class TestTeleportServiceExecute: mock_nuage.get_chat_assistant_url = AsyncMock( return_value="https://chat.example.com/123" ) - service._nuage = mock_nuage + service._nuage_client_instance = mock_nuage session = TeleportSession() events = [] @@ -282,6 +282,9 @@ class TestTeleportServiceExecute: assert isinstance(events[3], TeleportFetchingUrlEvent) assert isinstance(events[4], TeleportCompleteEvent) assert events[4].url == "https://chat.example.com/123" + workflow_params = mock_nuage.start_workflow.call_args.args[0] + assert workflow_params.integrations.chat_assistant is not None + assert workflow_params.integrations.chat_assistant.project_name is None @pytest.mark.asyncio async def test_execute_requires_push_and_user_approves( @@ -303,7 +306,7 @@ class TestTeleportServiceExecute: mock_nuage.get_chat_assistant_url = AsyncMock( return_value="https://chat.example.com/123" ) - service._nuage = mock_nuage + service._nuage_client_instance = mock_nuage session = TeleportSession() events = [] @@ -368,7 +371,7 @@ class TestTeleportServiceExecute: mock_nuage.get_chat_assistant_url = AsyncMock( return_value="https://chat.example.com/123" ) - service._nuage = mock_nuage + service._nuage_client_instance = mock_nuage session = TeleportSession() events = [] @@ -400,7 +403,7 @@ class TestTeleportServiceExecute: return_value=mock_github_connected ) mock_nuage.get_chat_assistant_url = AsyncMock(return_value=None) - service._nuage = mock_nuage + service._nuage_client_instance = mock_nuage session = TeleportSession() gen = service.execute("test prompt", session) @@ -427,7 +430,7 @@ class TestTeleportServiceExecute: mock_nuage.get_chat_assistant_url = AsyncMock( return_value="https://chat.example.com/123" ) - service._nuage = mock_nuage + service._nuage_client_instance = mock_nuage session = TeleportSession( messages=[{"role": "user", "content": "help me refactor"}] @@ -446,15 +449,15 @@ class TestTeleportServiceContextManager: mock_session_logger = MagicMock() service = TeleportService( session_logger=mock_session_logger, - nuage_base_url="https://api.example.com", - nuage_workflow_id="workflow-id", - nuage_api_key="api-key", + vibe_code_base_url="https://api.example.com", + vibe_code_workflow_id="workflow-id", + vibe_code_api_key="api-key", workdir=tmp_path, ) assert service._client is None async with service: assert service._client is not None - assert service._nuage is not None + assert service._nuage_client_instance is not None assert service._client is None diff --git a/tests/e2e/common.py b/tests/e2e/common.py index b352b4b..c17511c 100644 --- a/tests/e2e/common.py +++ b/tests/e2e/common.py @@ -49,15 +49,23 @@ def strip_ansi(text: str) -> str: return re.sub(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07", "", text) +def poll_until(predicate: Callable[[], bool], timeout: float, message: str) -> None: + start = time.monotonic() + while time.monotonic() - start < timeout: + if predicate(): + return + time.sleep(0.05) + raise AssertionError(message) + + def wait_for_request_count( 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 - time.sleep(0.05) - raise AssertionError(f"Timed out waiting for {expected_count} backend request(s).") + poll_until( + lambda: request_count_getter() >= expected_count, + timeout, + f"Timed out waiting for {expected_count} backend request(s).", + ) def wait_for_main_screen(child: pexpect.spawn, timeout: float = 20.0) -> None: @@ -84,3 +92,24 @@ def wait_for_rendered_text( raise AssertionError( f"Timed out waiting for rendered text: {needle!r}\n\nRendered tail:\n{rendered_tail}" ) + + +def send_ctrl_c_until_quit_confirmation( + child: pexpect.spawn, captured: io.StringIO, timeout: float = 3 +) -> None: + """Send Ctrl+C and wait for quit confirmation prompt. Retries if first Ctrl+C interrupts.""" + start = time.monotonic() + while time.monotonic() - start < timeout: + child.sendcontrol("c") + try: + child.expect(ansi_tolerant_pattern("Press Ctrl+C again to quit"), timeout=2) + # Confirmation prompt appeared, send second Ctrl+C + child.sendcontrol("c") + return + except pexpect.TIMEOUT: + # First Ctrl+C may have interrupted something, try again + continue + rendered_tail = strip_ansi(captured.getvalue())[-1200:] + raise AssertionError( + f"Timed out waiting for quit confirmation prompt.\n\nRendered tail:\n{rendered_tail}" + ) diff --git a/tests/e2e/test_cli_tui_hooks.py b/tests/e2e/test_cli_tui_hooks.py new file mode 100644 index 0000000..9de488f --- /dev/null +++ b/tests/e2e/test_cli_tui_hooks.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import tomllib + +import pexpect +import pytest +import tomli_w + +from tests.e2e.common import ( + SpawnedVibeProcessFixture, + poll_until, + send_ctrl_c_until_quit_confirmation, + wait_for_main_screen, + wait_for_rendered_text, + wait_for_request_count, +) +from tests.e2e.mock_server import StreamingMockServer + + +def _enable_hooks(vibe_home: Path, invocation_path: Path) -> None: + config_path = vibe_home / "config.toml" + config = tomllib.loads(config_path.read_text(encoding="utf-8")) + config["enable_experimental_hooks"] = True + config_path.write_bytes(tomli_w.dumps(config).encode()) + + script = vibe_home / "_record_hook.py" + script.write_text( + "import json, sys\n" + "from pathlib import Path\n" + f"Path({str(invocation_path)!r}).write_text(" + "json.dumps(json.load(sys.stdin)), encoding='utf-8')\n", + encoding="utf-8", + ) + with (vibe_home / "hooks.toml").open("wb") as f: + tomli_w.dump( + { + "hooks": [ + { + "name": "record-invocation", + "type": "post_agent_turn", + "command": f"uv run python {script}", + } + ] + }, + f, + ) + + +@pytest.mark.timeout(20) +def test_spawn_cli_runs_configured_hook_after_turn( + streaming_mock_server: StreamingMockServer, + setup_e2e_env: None, + e2e_workdir: Path, + spawned_vibe_process: SpawnedVibeProcessFixture, +) -> None: + vibe_home = Path(os.environ["VIBE_HOME"]) + invocation_path = vibe_home / "hook-invocation.json" + _enable_hooks(vibe_home, invocation_path) + + with spawned_vibe_process(e2e_workdir) as (child, captured): + wait_for_main_screen(child, timeout=15) + child.send("Run the configured hook") + child.send("\r") + + wait_for_request_count( + lambda: len(streaming_mock_server.requests), expected_count=1, timeout=10 + ) + wait_for_rendered_text( + child, captured, needle="Hello from mock server", timeout=10 + ) + poll_until( + invocation_path.is_file, + timeout=10, + message=f"Timed out waiting for hook output file: {invocation_path}", + ) + + send_ctrl_c_until_quit_confirmation(child, captured, timeout=5) + child.expect(pexpect.EOF, timeout=10) + + assert len(streaming_mock_server.requests) == 1 + invocation = json.loads(invocation_path.read_text(encoding="utf-8")) + assert invocation["hook_event_name"] == "post_agent_turn" + assert isinstance(invocation["cwd"], str) and invocation["cwd"] + assert isinstance(invocation["session_id"], str) and invocation["session_id"] diff --git a/tests/e2e/test_cli_tui_session_exit.py b/tests/e2e/test_cli_tui_session_exit.py index 42c4f6d..3074efa 100644 --- a/tests/e2e/test_cli_tui_session_exit.py +++ b/tests/e2e/test_cli_tui_session_exit.py @@ -12,6 +12,7 @@ import pytest from tests.e2e.common import ( SpawnedVibeProcessFixture, ansi_tolerant_pattern, + send_ctrl_c_until_quit_confirmation, strip_ansi, wait_for_main_screen, wait_for_request_count, @@ -103,7 +104,7 @@ def test_resumed_session_prints_only_fresh_token_usage_on_exit( request_count_getter=lambda: len(streaming_mock_server.requests), ) - child.sendcontrol("c") + send_ctrl_c_until_quit_confirmation(child, captured, timeout=5) child.expect(pexpect.EOF, timeout=10) first_output = strip_ansi(captured.getvalue()) @@ -130,7 +131,7 @@ def test_resumed_session_prints_only_fresh_token_usage_on_exit( request_count_getter=lambda: len(streaming_mock_server.requests), ) - resumed_child.sendcontrol("c") + send_ctrl_c_until_quit_confirmation(resumed_child, resumed_captured, timeout=5) resumed_child.expect(pexpect.EOF, timeout=10) second_output = strip_ansi(resumed_captured.getvalue()) diff --git a/tests/e2e/test_cli_tui_streaming.py b/tests/e2e/test_cli_tui_streaming.py index 0bca4d5..36bd2ec 100644 --- a/tests/e2e/test_cli_tui_streaming.py +++ b/tests/e2e/test_cli_tui_streaming.py @@ -8,6 +8,7 @@ import pytest from tests.e2e.common import ( SpawnedVibeProcessFixture, ansi_tolerant_pattern, + send_ctrl_c_until_quit_confirmation, wait_for_main_screen, wait_for_request_count, ) @@ -31,7 +32,7 @@ def test_spawn_cli_to_send_and_receive_message( ) child.expect(ansi_tolerant_pattern("Hello from mock server"), timeout=10) - child.sendcontrol("c") + send_ctrl_c_until_quit_confirmation(child, captured, timeout=5) child.expect(pexpect.EOF, timeout=10) output = captured.getvalue() diff --git a/tests/e2e/test_cli_tui_tool_approval.py b/tests/e2e/test_cli_tui_tool_approval.py index 6305f9b..58043cb 100644 --- a/tests/e2e/test_cli_tui_tool_approval.py +++ b/tests/e2e/test_cli_tui_tool_approval.py @@ -7,6 +7,7 @@ import pytest from tests.e2e.common import ( SpawnedVibeProcessFixture, + send_ctrl_c_until_quit_confirmation, wait_for_main_screen, wait_for_rendered_text, wait_for_request_count, @@ -83,5 +84,5 @@ def test_spawn_cli_asks_bash_permission_and_shows_tool_output_after_approval( child.send("\r") wait_for_rendered_text(child, captured, needle=PREDICTABLE_OUTPUT, timeout=10) - child.sendcontrol("c") + send_ctrl_c_until_quit_confirmation(child, captured, timeout=5) child.expect(pexpect.EOF, timeout=10) diff --git a/tests/onboarding/test_ui_onboarding.py b/tests/onboarding/test_ui_onboarding.py index 5883a48..3783f13 100644 --- a/tests/onboarding/test_ui_onboarding.py +++ b/tests/onboarding/test_ui_onboarding.py @@ -9,6 +9,9 @@ from textual.widgets import Input from vibe.core.config import ProviderConfig from vibe.core.paths import GLOBAL_ENV_FILE +from vibe.core.telemetry.build_metadata import build_entrypoint_metadata +from vibe.core.telemetry.send import TelemetryClient +from vibe.core.types import Backend from vibe.setup.onboarding import OnboardingApp from vibe.setup.onboarding.screens.api_key import ApiKeyScreen, persist_api_key @@ -118,3 +121,39 @@ def test_persist_api_key_returns_env_var_error_for_empty_env_var_name() -> None: result = persist_api_key(provider, "secret") assert result == "env_var_error:" + + +def test_persist_api_key_sends_onboarding_telemetry_with_entrypoint_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + recorded_metadata: dict[str, str] = {} + + def capture(self: TelemetryClient) -> None: + recorded_metadata.update(self.build_client_event_metadata()) + + monkeypatch.setattr(TelemetryClient, "send_onboarding_api_key_added", capture) + + provider = ProviderConfig( + name="mistral", + api_base="https://api.mistral.ai/v1", + api_key_env_var="MISTRAL_API_KEY", + backend=Backend.MISTRAL, + ) + + result = persist_api_key( + provider, + "secret", + entrypoint_metadata=build_entrypoint_metadata( + agent_entrypoint="cli", + agent_version="1.0.0", + client_name="vibe_cli", + client_version="1.0.0", + ), + ) + + assert result == "completed" + assert recorded_metadata["agent_entrypoint"] == "cli" + assert recorded_metadata["agent_version"] == "1.0.0" + assert recorded_metadata["client_name"] == "vibe_cli" + assert recorded_metadata["client_version"] == "1.0.0" + assert "session_id" not in recorded_metadata diff --git a/tests/session/test_resume_sessions.py b/tests/session/test_resume_sessions.py index 450fa04..c86a155 100644 --- a/tests/session/test_resume_sessions.py +++ b/tests/session/test_resume_sessions.py @@ -39,31 +39,31 @@ class TestShortSessionId: class TestListRemoteResumeSessions: @pytest.mark.asyncio - async def test_returns_empty_when_nuage_disabled(self) -> None: + async def test_returns_empty_when_vibe_code_disabled(self) -> None: config = MagicMock() - config.nuage_enabled = False - config.nuage_api_key = "key" + config.vibe_code_enabled = False + config.vibe_code_api_key = "key" result = await list_remote_resume_sessions(config) assert result == [] @pytest.mark.asyncio async def test_returns_empty_when_no_api_key(self) -> None: config = MagicMock() - config.nuage_enabled = True - config.nuage_api_key = None + config.vibe_code_enabled = True + config.vibe_code_api_key = None result = await list_remote_resume_sessions(config) assert result == [] @pytest.mark.asyncio async def test_returns_empty_when_both_missing(self) -> None: config = MagicMock() - config.nuage_enabled = False - config.nuage_api_key = None + config.vibe_code_enabled = False + config.vibe_code_api_key = None result = await list_remote_resume_sessions(config) assert result == [] @pytest.mark.asyncio - async def test_filters_only_active_statuses(self) -> None: + async def test_passes_active_statuses_to_api(self) -> None: from datetime import datetime from vibe.core.nuage.workflow import ( @@ -79,13 +79,6 @@ class TestListRemoteResumeSessions: start_time=datetime(2026, 1, 1), end_time=None, ) - completed = WorkflowExecutionWithoutResultResponse( - workflow_name="vibe", - execution_id="exec-completed", - status=WorkflowExecutionStatus.COMPLETED, - start_time=datetime(2026, 1, 1), - end_time=datetime(2026, 1, 2), - ) retrying = WorkflowExecutionWithoutResultResponse( workflow_name="vibe", execution_id="exec-retrying", @@ -93,17 +86,90 @@ class TestListRemoteResumeSessions: 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, completed, retrying] + executions=[running, retrying, continued] ) config = MagicMock() - config.nuage_enabled = True - config.nuage_api_key = "test-key" - config.nuage_base_url = "https://test.example.com" + 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.nuage_workflow_id = "workflow-1" + config.vibe_code_workflow_id = "workflow-1" + + with patch("vibe.core.session.resume_sessions.WorkflowsClient") as MockClient: + mock_client = AsyncMock() + mock_client.get_workflow_runs.return_value = mock_response + MockClient.return_value.__aenter__ = AsyncMock(return_value=mock_client) + MockClient.return_value.__aexit__ = AsyncMock(return_value=False) + + result = await list_remote_resume_sessions(config) + + assert len(result) == 3 + session_ids = {s.session_id for s in result} + assert "exec-running" in session_ids + assert "exec-retrying" in session_ids + assert "exec-continued" in session_ids + assert all(s.source == "remote" for s in result) + + mock_client.get_workflow_runs.assert_called_once_with( + workflow_identifier="workflow-1", + page_size=50, + status=[ + WorkflowExecutionStatus.RUNNING, + WorkflowExecutionStatus.RETRYING_AFTER_ERROR, + WorkflowExecutionStatus.CONTINUED_AS_NEW, + ], + ) + + @pytest.mark.asyncio + async def test_deduplicates_execution_ids_keeps_latest(self) -> None: + from datetime import datetime + + from vibe.core.nuage.workflow import ( + WorkflowExecutionListResponse, + WorkflowExecutionStatus, + WorkflowExecutionWithoutResultResponse, + ) + + 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]) + + 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" with patch("vibe.core.session.resume_sessions.WorkflowsClient") as MockClient: mock_client = AsyncMock() @@ -114,8 +180,54 @@ class TestListRemoteResumeSessions: result = await list_remote_resume_sessions(config) assert len(result) == 2 - session_ids = {s.session_id for s in result} - assert "exec-running" in session_ids - assert "exec-retrying" in session_ids - assert "exec-completed" not in session_ids - assert all(s.source == "remote" for s in result) + 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: + from datetime import datetime + + from vibe.core.nuage.workflow import ( + WorkflowExecutionListResponse, + WorkflowExecutionStatus, + WorkflowExecutionWithoutResultResponse, + ) + + previous = WorkflowExecutionWithoutResultResponse( + workflow_name="vibe", + execution_id="exec-1", + status=WorkflowExecutionStatus.RETRYING_AFTER_ERROR, + 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]) + + 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" + + with patch("vibe.core.session.resume_sessions.WorkflowsClient") as MockClient: + mock_client = AsyncMock() + mock_client.get_workflow_runs.return_value = mock_response + MockClient.return_value.__aenter__ = AsyncMock(return_value=mock_client) + MockClient.return_value.__aexit__ = AsyncMock(return_value=False) + + result = await list_remote_resume_sessions(config) + + assert len(result) == 1 + assert result[0].session_id == "exec-1" + assert result[0].status == WorkflowExecutionStatus.RUNNING diff --git a/tests/session/test_session_loader.py b/tests/session/test_session_loader.py index dfa1ce2..d43f1f7 100644 --- a/tests/session/test_session_loader.py +++ b/tests/session/test_session_loader.py @@ -39,10 +39,11 @@ def create_test_session(): messages: list[LLMMessage] | None = None, metadata: dict | None = None, encoding: str = "utf-8", + working_directory: Path | None = Path("/test"), ) -> Path: """Create a test session directory with messages and metadata files.""" # Create session directory - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") session_folder = session_dir / f"test_{timestamp}_{session_id[:8]}" session_folder.mkdir(exist_ok=True) @@ -79,7 +80,7 @@ def create_test_session(): }, "system_prompt": {"content": "System prompt", "role": "system"}, "username": "testuser", - "environment": {"working_directory": "/test"}, + "environment": {"working_directory": str(working_directory)}, "git_commit": None, "git_branch": None, } @@ -129,6 +130,93 @@ class TestSessionLoaderFindLatestSession: assert result.exists() assert result == latest + @pytest.mark.parametrize( + ("cwd", "expected_id"), + [ + pytest.param( + Path("/home/user/project-a"), "aaaaaaaa", id="get_latest_in_existing_a" + ), + pytest.param( + Path("/home/user/project-b"), "bbbbbbbb", id="get_latest_in_existing_b" + ), + pytest.param( + Path("/home/user/project-c"), None, id="get_latest_in_missing_c" + ), + pytest.param(None, "aaaaaaaa", id="get_latest_globally"), + ], + ) + def test_find_latest_session_cwd_filtering( + self, + session_config: SessionLoggingConfig, + create_test_session, + cwd: Path | None, + expected_id: str | None, + ) -> None: + session_dir = Path(session_config.save_dir) + + create_test_session( + session_dir, + "aaaaaaaa-session", + working_directory=Path("/home/user/project-a"), + ) + time.sleep(0.01) + create_test_session( + session_dir, + "bbbbbbbb-session", + working_directory=Path("/home/user/project-b"), + ) + time.sleep(0.01) + second_b = create_test_session( + session_dir, + "bbbbbbbb-session", + working_directory=Path("/home/user/project-b"), + ) + time.sleep(0.01) + second_a = create_test_session( + session_dir, + "aaaaaaaa-session", + working_directory=Path("/home/user/project-a"), + ) + + assert len(list(session_dir.glob("test_*"))) == 4 + + expected: Path | None + if expected_id == "aaaaaaaa": + expected = second_a + elif expected_id == "bbbbbbbb": + expected = second_b + elif expected_id is None: + expected = None + else: + raise NotImplementedError(expected_id) + + result = SessionLoader.find_latest_session( + session_config, working_directory=cwd + ) + assert result == expected + + def test_find_latest_session_cwd_filtering_skips_invalid_metadata( + self, session_config: SessionLoggingConfig, create_test_session + ) -> None: + session_dir = Path(session_config.save_dir) + expected = create_test_session( + session_dir, + "valid-cwd-session", + working_directory=Path("/home/user/project-a"), + ) + time.sleep(0.01) + invalid_metadata_session = create_test_session( + session_dir, + "invalid-metadata", + working_directory=Path("/home/user/project-a"), + ) + (invalid_metadata_session / "meta.json").write_text("{}") + + result = SessionLoader.find_latest_session( + session_config, working_directory=Path("/home/user/project-a") + ) + assert result == expected + def test_find_latest_session_nonexistent_save_dir(self) -> None: """Test finding latest session when save directory doesn't exist.""" # Modify config to point to non-existent directory diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg index b1e32f7..8f1f6d2 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg @@ -119,7 +119,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg index 98ccc8d..1a8f27f 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg @@ -153,7 +153,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg index e657c9f..985076e 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg @@ -180,7 +180,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg index 50fb83e..6d35c44 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 @@ -180,7 +180,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_escape_closes.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_escape_closes.svg index 5c1e5c4..acf2b79 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_escape_closes.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_escape_closes.svg @@ -181,7 +181,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_initial.svg index 5ff9950..0088172 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_initial.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_initial.svg @@ -161,7 +161,7 @@ - + @@ -181,18 +181,18 @@ - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - -Configuration opened... +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +Configuration opened... + - -┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -Settings - -Model: devstral-latest +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +Settings + +Model: devstral-latest +Thinking: Off Auto-copy: On Autocomplete watcher (may delay first autocompletion): Off diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_navigate_down.svg index a91a8fe..ef4e936 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_navigate_down.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_navigate_down.svg @@ -161,7 +161,7 @@ - + @@ -181,19 +181,19 @@ - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - -Configuration opened... +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +Configuration opened... + - -┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -Settings - -Model: devstral-latest -Auto-copy: On +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +Settings + +Model: devstral-latest +Thinking: Off +Auto-copy: On Autocomplete watcher (may delay first autocompletion): Off ↑↓ Navigate  Enter Select/Toggle  Esc Exit diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_toggle_autocopy.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_toggle_autocopy.svg index b241c10..eac385f 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_toggle_autocopy.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_config_app/test_snapshot_config_toggle_autocopy.svg @@ -181,18 +181,18 @@ - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - -Configuration opened... +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +Configuration opened... + - -┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -Settings - -Model: devstral-latest +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +Settings + +Model: devstral-latest +Thinking: Off Auto-copy: Off Autocomplete watcher (may delay first autocompletion): Off 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 b5c3660..a670b4e 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 @@ -179,7 +179,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_close.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_close.svg index ba3dd59..be678bb 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_close.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_close.svg @@ -183,7 +183,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_live_append.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_live_append.svg index 540c3c8..0f266a9 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_live_append.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_live_append.svg @@ -187,7 +187,7 @@ passed 2026-02-21 10:28:53CRITICAL Out of memory    error detected -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_open.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_open.svg index 469b9fa..adb6011 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_open.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_debug_console/test_snapshot_debug_console_open.svg @@ -186,7 +186,7 @@ passed -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_empty_assistant_before_reasoning/test_snapshot_empty_assistant_removed_when_reasoning_starts.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_empty_assistant_before_reasoning/test_snapshot_empty_assistant_removed_when_reasoning_starts.svg index 027a748..5c7a8a0 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_empty_assistant_before_reasoning/test_snapshot_empty_assistant_removed_when_reasoning_starts.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_empty_assistant_before_reasoning/test_snapshot_empty_assistant_removed_when_reasoning_starts.svg @@ -180,7 +180,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_feedback_bar/test_snapshot_feedback_bar_visible.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_feedback_bar/test_snapshot_feedback_bar_visible.svg index 11c4b7b..c599ffb 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_feedback_bar/test_snapshot_feedback_bar_visible.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_feedback_bar/test_snapshot_feedback_bar_visible.svg @@ -181,7 +181,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg index 6c8dfdf..1a2971c 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg @@ -19,187 +19,187 @@ font-weight: 700; } - .terminal-4926488215-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4926488215-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4926488215-r1 { fill: #c5c8c6 } -.terminal-4926488215-r2 { fill: #ff8205;font-weight: bold } -.terminal-4926488215-r3 { fill: #68a0b3 } -.terminal-4926488215-r4 { fill: #ff8205 } -.terminal-4926488215-r5 { fill: #9a9b99 } -.terminal-4926488215-r6 { fill: #608ab1;font-weight: bold } -.terminal-4926488215-r7 { fill: #c5c8c6;font-weight: bold } -.terminal-4926488215-r8 { fill: #9cafbd;font-weight: bold } -.terminal-4926488215-r9 { fill: #868887 } + .terminal-r1 { fill: #c5c8c6 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #9a9b99 } +.terminal-r6 { fill: #608ab1;font-weight: bold } +.terminal-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-r8 { fill: #9cafbd;font-weight: bold } +.terminal-r9 { fill: #868887 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithMcpServers + SnapshotTestAppWithMcpServers - + - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp filesystem -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers - -Local MCP Servers -  filesystem  [stdio]  1 tool -  search      [http]   1 tool - -↑↓ Navigate  Enter Show tools  R Refresh  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp filesystem +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem  [stdio]  1 tool +  search      [http]   1 tool + +↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg index edf910a..6f20c7a 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg @@ -19,187 +19,187 @@ font-weight: 700; } - .terminal-2811199383-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2811199383-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2811199383-r1 { fill: #c5c8c6 } -.terminal-2811199383-r2 { fill: #ff8205;font-weight: bold } -.terminal-2811199383-r3 { fill: #68a0b3 } -.terminal-2811199383-r4 { fill: #ff8205 } -.terminal-2811199383-r5 { fill: #9a9b99 } -.terminal-2811199383-r6 { fill: #608ab1;font-weight: bold } -.terminal-2811199383-r7 { fill: #c5c8c6;font-weight: bold } -.terminal-2811199383-r8 { fill: #9cafbd;font-weight: bold } -.terminal-2811199383-r9 { fill: #868887 } + .terminal-r1 { fill: #c5c8c6 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #9a9b99 } +.terminal-r6 { fill: #608ab1;font-weight: bold } +.terminal-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-r8 { fill: #9cafbd;font-weight: bold } +.terminal-r9 { fill: #868887 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithBrokenMcpServer + SnapshotTestAppWithBrokenMcpServer - + - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers - -Local MCP Servers -  filesystem     [stdio]  1 tool -  broken-server  [stdio]  no tools -  search         [http]   1 tool - -↑↓ Navigate  Enter Show tools  R Refresh  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem     [stdio]  1 tool +  broken-server  [stdio]  no tools +  search         [http]   1 tool + +↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg index cd77d5e..3b4c923 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg @@ -19,188 +19,188 @@ font-weight: 700; } - .terminal-6499786123-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-6499786123-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-6499786123-r1 { fill: #c5c8c6 } -.terminal-6499786123-r2 { fill: #ff8205;font-weight: bold } -.terminal-6499786123-r3 { fill: #68a0b3 } -.terminal-6499786123-r4 { fill: #ff8205 } -.terminal-6499786123-r5 { fill: #9a9b99 } -.terminal-6499786123-r6 { fill: #608ab1;font-weight: bold } -.terminal-6499786123-r7 { fill: #c5c8c6;font-weight: bold } -.terminal-6499786123-r8 { fill: #9cafbd;font-weight: bold } -.terminal-6499786123-r9 { fill: #868887 } -.terminal-6499786123-r10 { fill: #98a84b } + .terminal-r1 { fill: #c5c8c6 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #9a9b99 } +.terminal-r6 { fill: #608ab1;font-weight: bold } +.terminal-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-r8 { fill: #9cafbd;font-weight: bold } +.terminal-r9 { fill: #868887 } +.terminal-r10 { fill: #98a84b } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithConnectors + SnapshotTestAppWithConnectors - + - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers & Connectors - -Local MCP Servers -  filesystem  [stdio]  1 tool - -Workspace Connectors -  gmail  [connector]  3 tools connected -  slack  [connector]  2 tools connected - -↑↓ Navigate  Enter Show tools  R Refresh  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers & Connectors + +Local MCP Servers +  filesystem  [stdio]  1 tool + +Workspace Connectors +  gmail  [connector]  3 tools connected +  slack  [connector]  2 tools connected + +↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg index f2a1d97..fe45260 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg @@ -19,189 +19,189 @@ font-weight: 700; } - .terminal-4242019355-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4242019355-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4242019355-r1 { fill: #c5c8c6 } -.terminal-4242019355-r2 { fill: #ff8205;font-weight: bold } -.terminal-4242019355-r3 { fill: #68a0b3 } -.terminal-4242019355-r4 { fill: #ff8205 } -.terminal-4242019355-r5 { fill: #9a9b99 } -.terminal-4242019355-r6 { fill: #608ab1;font-weight: bold } -.terminal-4242019355-r7 { fill: #c5c8c6;font-weight: bold } -.terminal-4242019355-r8 { fill: #9cafbd;font-weight: bold } -.terminal-4242019355-r9 { fill: #98a84b;font-weight: bold } -.terminal-4242019355-r10 { fill: #868887 } -.terminal-4242019355-r11 { fill: #98a84b } + .terminal-r1 { fill: #c5c8c6 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #9a9b99 } +.terminal-r6 { fill: #608ab1;font-weight: bold } +.terminal-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-r8 { fill: #9cafbd;font-weight: bold } +.terminal-r9 { fill: #98a84b;font-weight: bold } +.terminal-r10 { fill: #868887 } +.terminal-r11 { fill: #98a84b } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppConnectorsOnly + SnapshotTestAppConnectorsOnly - + - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers & Connectors - -Workspace Connectors -  gmail  [connector]  3 tools connected -  slack  [connector]  2 tools connected - -↑↓ Navigate  Enter Show tools  R Refresh  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers & Connectors + +Workspace Connectors +  gmail  [connector]  3 tools connected +  slack  [connector]  2 tools connected + +↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg index eacd6d8..bb69b8d 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg @@ -181,7 +181,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 3 connectors · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information @@ -198,7 +198,7 @@   beta   [connector]  no tools not connected   zeta   [connector]  no tools not connected -↑↓ Navigate  Enter Show tools  R Refresh  Esc Close +↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ /test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg index 760f20a..59f7d9f 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg @@ -19,185 +19,185 @@ font-weight: 700; } - .terminal-1928557542-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1928557542-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1928557542-r1 { fill: #c5c8c6 } -.terminal-1928557542-r2 { fill: #ff8205;font-weight: bold } -.terminal-1928557542-r3 { fill: #68a0b3 } -.terminal-1928557542-r4 { fill: #ff8205 } -.terminal-1928557542-r5 { fill: #9a9b99 } -.terminal-1928557542-r6 { fill: #608ab1;font-weight: bold } -.terminal-1928557542-r7 { fill: #c5c8c6;font-weight: bold } + .terminal-r1 { fill: #c5c8c6 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #9a9b99 } +.terminal-r6 { fill: #608ab1;font-weight: bold } +.terminal-r7 { fill: #c5c8c6;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithConnectors + SnapshotTestAppWithConnectors - + - - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -Connector: slack - -search_messages  -  Search Slack messages -send_message  -  Send a Slack message - -↑↓ Navigate  Backspace Back  R Refresh  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +Connector: slack + +search_messages  -  Search Slack messages +send_message  -  Send a Slack message + +↑↓ Navigate  D Disable  E Enable  Backspace Back  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg index 59cc3b7..16b75c9 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg @@ -19,185 +19,185 @@ font-weight: 700; } - .terminal-4237726511-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4237726511-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4237726511-r1 { fill: #c5c8c6 } -.terminal-4237726511-r2 { fill: #ff8205;font-weight: bold } -.terminal-4237726511-r3 { fill: #68a0b3 } -.terminal-4237726511-r4 { fill: #ff8205 } -.terminal-4237726511-r5 { fill: #9a9b99 } -.terminal-4237726511-r6 { fill: #608ab1;font-weight: bold } -.terminal-4237726511-r7 { fill: #c5c8c6;font-weight: bold } + .terminal-r1 { fill: #c5c8c6 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #9a9b99 } +.terminal-r6 { fill: #608ab1;font-weight: bold } +.terminal-r7 { fill: #c5c8c6;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithMcpServers + SnapshotTestAppWithMcpServers - + - - - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Server: filesystem - -fake_tool  -  A fake tool for filesystem - -↑↓ Navigate  Backspace Back  R Refresh  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Server: filesystem + +fake_tool  -  A fake tool for filesystem + +↑↓ Navigate  D Disable  E Enable  Backspace Back  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_escape_closes.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_escape_closes.svg index 9e3b2e7..fda8223 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_escape_closes.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_escape_closes.svg @@ -19,183 +19,183 @@ font-weight: 700; } - .terminal-7514237046-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-7514237046-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-7514237046-r1 { fill: #c5c8c6 } -.terminal-7514237046-r2 { fill: #ff8205;font-weight: bold } -.terminal-7514237046-r3 { fill: #68a0b3 } -.terminal-7514237046-r4 { fill: #ff8205 } -.terminal-7514237046-r5 { fill: #9a9b99 } + .terminal-r1 { fill: #c5c8c6 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #9a9b99 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithMcpServers + SnapshotTestAppWithMcpServers - + - - - - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... -MCP servers closed. - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ -> - - -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... +MCP servers closed. + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_no_servers.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_no_servers.svg index 57b68f5..fcf8275 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_no_servers.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_no_servers.svg @@ -181,7 +181,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg index fdee10e..70f1944 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg @@ -19,187 +19,187 @@ font-weight: 700; } - .terminal-606925797-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-606925797-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-606925797-r1 { fill: #c5c8c6 } -.terminal-606925797-r2 { fill: #ff8205;font-weight: bold } -.terminal-606925797-r3 { fill: #68a0b3 } -.terminal-606925797-r4 { fill: #ff8205 } -.terminal-606925797-r5 { fill: #9a9b99 } -.terminal-606925797-r6 { fill: #608ab1;font-weight: bold } -.terminal-606925797-r7 { fill: #c5c8c6;font-weight: bold } -.terminal-606925797-r8 { fill: #9cafbd;font-weight: bold } -.terminal-606925797-r9 { fill: #868887 } + .terminal-r1 { fill: #c5c8c6 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #9a9b99 } +.terminal-r6 { fill: #608ab1;font-weight: bold } +.terminal-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-r8 { fill: #9cafbd;font-weight: bold } +.terminal-r9 { fill: #868887 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithMcpServers + SnapshotTestAppWithMcpServers - + - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers - -Local MCP Servers -  filesystem  [stdio]  1 tool -  search      [http]   1 tool - -↑↓ Navigate  Enter Show tools  R Refresh  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem  [stdio]  1 tool +  search      [http]   1 tool + +↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg index 4a22e1f..5694c37 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg @@ -19,187 +19,187 @@ font-weight: 700; } - .terminal-9601757275-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-9601757275-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-9601757275-r1 { fill: #c5c8c6 } -.terminal-9601757275-r2 { fill: #ff8205;font-weight: bold } -.terminal-9601757275-r3 { fill: #68a0b3 } -.terminal-9601757275-r4 { fill: #ff8205 } -.terminal-9601757275-r5 { fill: #9a9b99 } -.terminal-9601757275-r6 { fill: #608ab1;font-weight: bold } -.terminal-9601757275-r7 { fill: #c5c8c6;font-weight: bold } -.terminal-9601757275-r8 { fill: #868887 } -.terminal-9601757275-r9 { fill: #9cafbd;font-weight: bold } + .terminal-r1 { fill: #c5c8c6 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #9a9b99 } +.terminal-r6 { fill: #608ab1;font-weight: bold } +.terminal-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-r8 { fill: #868887 } +.terminal-r9 { fill: #9cafbd;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithMcpServers + SnapshotTestAppWithMcpServers - + - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers - -Local MCP Servers -  filesystem  [stdio]  1 tool -  search      [http]   1 tool - -↑↓ Navigate  Enter Show tools  R Refresh  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem  [stdio]  1 tool +  search      [http]   1 tool + +↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg index 010c423..25716a9 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg @@ -19,187 +19,187 @@ font-weight: 700; } - .terminal-3878668141-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3878668141-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3878668141-r1 { fill: #c5c8c6 } -.terminal-3878668141-r2 { fill: #ff8205;font-weight: bold } -.terminal-3878668141-r3 { fill: #68a0b3 } -.terminal-3878668141-r4 { fill: #ff8205 } -.terminal-3878668141-r5 { fill: #9a9b99 } -.terminal-3878668141-r6 { fill: #608ab1;font-weight: bold } -.terminal-3878668141-r7 { fill: #c5c8c6;font-weight: bold } -.terminal-3878668141-r8 { fill: #9cafbd;font-weight: bold } -.terminal-3878668141-r9 { fill: #868887 } + .terminal-r1 { fill: #c5c8c6 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #9a9b99 } +.terminal-r6 { fill: #608ab1;font-weight: bold } +.terminal-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-r8 { fill: #9cafbd;font-weight: bold } +.terminal-r9 { fill: #868887 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithMcpServers + SnapshotTestAppWithMcpServers - + - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers - -Local MCP Servers -  filesystem  [stdio]  1 tool -  search      [http]   1 tool - -Refreshed.  ↑↓ Navigate  Enter Show tools  R Refresh  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem  [stdio]  1 tool +  search      [http]   1 tool + +Refreshed.  ↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg index 95b8cbd..703996f 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg @@ -19,185 +19,185 @@ font-weight: 700; } - .terminal-5744808340-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-5744808340-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-5744808340-r1 { fill: #c5c8c6 } -.terminal-5744808340-r2 { fill: #ff8205;font-weight: bold } -.terminal-5744808340-r3 { fill: #68a0b3 } -.terminal-5744808340-r4 { fill: #ff8205 } -.terminal-5744808340-r5 { fill: #9a9b99 } -.terminal-5744808340-r6 { fill: #608ab1;font-weight: bold } -.terminal-5744808340-r7 { fill: #c5c8c6;font-weight: bold } + .terminal-r1 { fill: #c5c8c6 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #9a9b99 } +.terminal-r6 { fill: #608ab1;font-weight: bold } +.terminal-r7 { fill: #c5c8c6;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithMcpServers + SnapshotTestAppWithMcpServers - + - - - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp filesystem -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Server: filesystem - -fake_tool  -  A fake tool for filesystem - -↑↓ Navigate  Backspace Back  R Refresh  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp filesystem +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Server: filesystem + +fake_tool  -  A fake tool for filesystem + +↑↓ Navigate  D Disable  E Enable  Backspace Back  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg index b7cb050..3b4c923 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg @@ -19,188 +19,188 @@ font-weight: 700; } - .terminal-4913728344-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4913728344-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4913728344-r1 { fill: #c5c8c6 } -.terminal-4913728344-r2 { fill: #ff8205;font-weight: bold } -.terminal-4913728344-r3 { fill: #68a0b3 } -.terminal-4913728344-r4 { fill: #ff8205 } -.terminal-4913728344-r5 { fill: #9a9b99 } -.terminal-4913728344-r6 { fill: #608ab1;font-weight: bold } -.terminal-4913728344-r7 { fill: #c5c8c6;font-weight: bold } -.terminal-4913728344-r8 { fill: #9cafbd;font-weight: bold } -.terminal-4913728344-r9 { fill: #868887 } -.terminal-4913728344-r10 { fill: #98a84b } + .terminal-r1 { fill: #c5c8c6 } +.terminal-r2 { fill: #ff8205;font-weight: bold } +.terminal-r3 { fill: #68a0b3 } +.terminal-r4 { fill: #ff8205 } +.terminal-r5 { fill: #9a9b99 } +.terminal-r6 { fill: #608ab1;font-weight: bold } +.terminal-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-r8 { fill: #9cafbd;font-weight: bold } +.terminal-r9 { fill: #868887 } +.terminal-r10 { fill: #98a84b } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithConnectors + SnapshotTestAppWithConnectors - + - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers & Connectors - -Local MCP Servers -  filesystem  [stdio]  1 tool - -Workspace Connectors -  gmail  [connector]  3 tools connected -  slack  [connector]  2 tools connected - -↑↓ Navigate  Enter Show tools  R Refresh  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers & Connectors + +Local MCP Servers +  filesystem  [stdio]  1 tool + +Workspace Connectors +  gmail  [connector]  3 tools connected +  slack  [connector]  2 tools connected + +↑↓ Navigate  Enter Show tools  D Disable  E Enable  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_escape_cancels.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_escape_cancels.svg index 61c79b7..2ecc5ba 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_escape_cancels.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_escape_cancels.svg @@ -183,7 +183,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣5 models · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_initial.svg index af465f2..cad57c6 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_initial.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_initial.svg @@ -181,7 +181,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣5 models · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_navigate_down.svg index e667a4c..1945313 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_navigate_down.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_navigate_down.svg @@ -181,7 +181,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣5 models · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_select_different_model.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_select_different_model.svg index 6097a75..7cab259 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_select_different_model.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_model_picker/test_snapshot_model_picker_select_different_model.svg @@ -182,7 +182,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg index 3a7ab03..bdd506e 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg @@ -184,7 +184,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg index c6ba236..1b42b8a 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg @@ -184,7 +184,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg index cb7577a..d558a13 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg @@ -184,7 +184,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg index d89f86c..1f6be05 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg @@ -183,7 +183,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg index d89f86c..1f6be05 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg @@ -183,7 +183,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_idle_after_speaking.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_idle_after_speaking.svg index 9e9726b..d414858 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_idle_after_speaking.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_idle_after_speaking.svg @@ -180,7 +180,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_speaking.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_speaking.svg index dc1df20..db23c22 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_speaking.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_speaking.svg @@ -182,7 +182,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information @@ -191,7 +191,7 @@ Hello! I can help you. -▂▅▇ speaking esc to stop +▂▅▇ speaking Esc/Ctrl+C to stop ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ > diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_summarizing.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_summarizing.svg index 96d4d43..89bacf3 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_summarizing.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_narrator_flow/test_snapshot_narrator_summarizing.svg @@ -182,7 +182,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information @@ -191,7 +191,7 @@ Hello! I can help you. - summarizing esc to stop + summarizing Esc/Ctrl+C to stop ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ > diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_cancel_discards_changes.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_cancel_discards_changes.svg index e6e9676..4663f2a 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_cancel_discards_changes.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_cancel_discards_changes.svg @@ -181,7 +181,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_edit_existing_values.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_edit_existing_values.svg index 5485f2a..bd9ae0c 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_edit_existing_values.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_edit_existing_values.svg @@ -181,7 +181,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_empty.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_empty.svg index 3d65f62..49d37fc 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_empty.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_empty.svg @@ -174,7 +174,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_with_values.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_with_values.svg index dd186bb..fe937cc 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_with_values.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_initial_with_values.svg @@ -173,7 +173,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_error.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_error.svg index c13bd3e..1bd51c1 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_error.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_error.svg @@ -182,7 +182,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_new_values.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_new_values.svg index b9d9318..abe2d81 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_new_values.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_proxy_setup/test_snapshot_proxy_setup_save_new_values.svg @@ -181,7 +181,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg index 3c23cc3..88d16d2 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg @@ -180,7 +180,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg index baf875c..9c518bf 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg @@ -176,7 +176,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg index 2e0bc7b..3a077b2 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg @@ -180,7 +180,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg index 1e3c517..f1dd134 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg @@ -179,7 +179,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg index 4b8fa61..78f4812 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg @@ -183,7 +183,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_error_shows_toast.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_error_shows_toast.svg index 1bd6ce6..ae3f560 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_error_shows_toast.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_error_shows_toast.svg @@ -176,7 +176,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_exit_on_escape.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_exit_on_escape.svg index 7955200..55313db 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_exit_on_escape.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_exit_on_escape.svg @@ -176,7 +176,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_down.svg index 4914668..d838e0b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_down.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_down.svg @@ -175,7 +175,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_up.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_up.svg index 51266ac..cccabe9 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_up.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_navigate_up.svg @@ -175,7 +175,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_panel_shown.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_panel_shown.svg index 4914668..d838e0b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_panel_shown.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_rewind/test_snapshot_rewind_panel_shown.svg @@ -175,7 +175,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg index 77408ba..1199b62 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg @@ -179,7 +179,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume_injected/test_snapshot_session_resume_hides_injected_messages.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume_injected/test_snapshot_session_resume_hides_injected_messages.svg index 8f5f1f7..52cab7a 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume_injected/test_snapshot_session_resume_hides_injected_messages.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume_injected/test_snapshot_session_resume_hides_injected_messages.svg @@ -176,7 +176,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_code_fence/test_snapshot_streaming_code_fence_preserved.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_code_fence/test_snapshot_streaming_code_fence_preserved.svg index c270a9e..4cbdb24 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_code_fence/test_snapshot_streaming_code_fence_preserved.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_streaming_code_fence/test_snapshot_streaming_code_fence_preserved.svg @@ -166,7 +166,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information 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 new file mode 100644 index 0000000..485db56 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_hidden_for_non_pro_account.svg @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TeleportCommandHelpFreeApp + + + + + + + + + +  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +Keyboard Shortcuts +• Enter Submit message +• Ctrl+J / Shift+Enter Insert newline +• Escape Interrupt agent or close dialogs +• Ctrl+C Quit (or clear input if text present) +• Ctrl+G Edit input in external editor +• Ctrl+O Toggle tool output view +• Shift+Tab Toggle auto-approve mode +• Alt+↑↓ / Ctrl+P/N Rewind to previous/next message + +Special Features + +• !<command> Execute bash command directly +• @path/to/file/ Autocompletes file paths + +Commands + +• /help: Show help message +• /config: Edit config settings +• /model: Select active model +• /thinking: Select thinking level +• /reload: Reload configuration, agent instructions, and skills from disk +• /clear: Clear conversation history +• /copy: Copy the last agent message to the clipboard +• /log: Show path to current interaction log file +• /debug: Toggle debug console +• /compact: Compact conversation history by summarizing. Optionally pass instructions to guide the summary +• /exit: Exit the application +• /status: Display agent statistics +• /proxy-setup: Configure proxy and SSL certificate settings +• /continue/resume: Browse and resume past sessions +• /connectors/mcp: Display available MCP servers and connectors. Pass a name to list its tools +• /voice: Configure voice settings +• /leanstall: Install the Lean 4 agent (leanstral) +• /unleanstall: Uninstall the Lean 4 agent +• /rewind: Rewind to a previous message +• /data-retention: Show data retention information + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + 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 new file mode 100644 index 0000000..923ef43 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_command_visible_for_pro_account.svg @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TeleportCommandHelpProApp + + + + + + + + + +   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + +Keyboard Shortcuts +• Enter Submit message +• Ctrl+J / Shift+Enter Insert newline +• Escape Interrupt agent or close dialogs +• Ctrl+C Quit (or clear input if text present) +• Ctrl+G Edit input in external editor +• Ctrl+O Toggle tool output view +• Shift+Tab Toggle auto-approve mode +• Alt+↑↓ / Ctrl+P/N Rewind to previous/next message + +Special Features + +• !<command> Execute bash command directly +• @path/to/file/ Autocompletes file paths + +Commands + +• /help: Show help message +• /config: Edit config settings +• /model: Select active model +• /thinking: Select thinking level +• /reload: Reload configuration, agent instructions, and skills from disk +• /clear: Clear conversation history +• /copy: Copy the last agent message to the clipboard +• /log: Show path to current interaction log file +• /debug: Toggle debug console +• /compact: Compact conversation history by summarizing. Optionally pass instructions to guide the summary +• /exit: Exit the application +• /status: Display agent statistics +• /teleport: Teleport session to Vibe Code +• /proxy-setup: Configure proxy and SSL certificate settings +• /continue/resume: Browse and resume past sessions +• /connectors/mcp: Display available MCP servers and connectors. Pass a name to list its tools +• /voice: Configure voice settings +• /leanstall: Install the Lean 4 agent (leanstral) +• /unleanstall: Uninstall the Lean 4 agent +• /rewind: Rewind to a previous message +• /data-retention: Show data retention information + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_complete.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_complete.svg index b61aed2..d3bdf13 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_complete.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_complete.svg @@ -78,9 +78,9 @@ - + - Teleported to a new async coding session: https://chat.example.com + Teleported to Vibe Code: https://chat.example.com diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_starting_workflow.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_starting_workflow.svg index b77bc80..161a89f 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_starting_workflow.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_teleport/test_snapshot_teleport_status_starting_workflow.svg @@ -77,9 +77,9 @@ - + - Starting Nuage workflow... + Starting Vibe Code session... diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_indicator_shown.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_indicator_shown.svg index 5ff233d..ecf49d7 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_indicator_shown.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_indicator_shown.svg @@ -184,7 +184,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_not_started_when_voice_disabled.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_not_started_when_voice_disabled.svg index a100082..4f31150 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_not_started_when_voice_disabled.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_not_started_when_voice_disabled.svg @@ -183,7 +183,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_stopped_after_keypress.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_stopped_after_keypress.svg index 94e5d41..2113207 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_stopped_after_keypress.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_stopped_after_keypress.svg @@ -183,7 +183,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_then_conversation.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_then_conversation.svg index e9764e2..937cddc 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_then_conversation.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_recording_then_conversation.svg @@ -180,7 +180,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_transcription_populates_text_area.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_transcription_populates_text_area.svg index 50a0292..8b7fef6 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_transcription_populates_text_area.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_transcription_populates_text_area.svg @@ -184,7 +184,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_disable.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_disable.svg index 1028a3a..30c54d8 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_disable.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_disable.svg @@ -180,7 +180,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_enable.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_enable.svg index 3c38282..ebee884 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_enable.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_voice_mode/test_snapshot_voice_enable.svg @@ -180,7 +180,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg index ab0fed7..3028486 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg @@ -180,7 +180,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg index b07c2b4..3ab2300 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg @@ -178,7 +178,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [API] Experiment plan +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [API] Experiment plan  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg index 6f07c71..cbcca0d 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg @@ -178,7 +178,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [API] Experiment plan +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [API] Experiment plan  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg index 57a1d1e..57494bd 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg @@ -180,7 +180,7 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off]  ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills   ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/conftest.py b/tests/snapshots/conftest.py index 5a79ae0..adee63c 100644 --- a/tests/snapshots/conftest.py +++ b/tests/snapshots/conftest.py @@ -20,4 +20,5 @@ def _pin_banner_version(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.fixture(autouse=True) def _pin_alt_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("vibe.cli.commands.ALT_KEY", "Alt") monkeypatch.setattr("vibe.cli.textual_ui.widgets.rewind_app.ALT_KEY", "Alt") diff --git a/tests/snapshots/test_ui_snapshot_config_app.py b/tests/snapshots/test_ui_snapshot_config_app.py index 7eae2b5..ff5a531 100644 --- a/tests/snapshots/test_ui_snapshot_config_app.py +++ b/tests/snapshots/test_ui_snapshot_config_app.py @@ -40,6 +40,7 @@ def test_snapshot_config_toggle_autocopy(snap_compare: SnapCompare) -> None: async def run_before(pilot: Pilot) -> None: await pilot.pause(0.2) await pilot.press("down") + await pilot.press("down") await pilot.press("enter") await pilot.pause(0.1) diff --git a/tests/snapshots/test_ui_snapshot_feedback_bar.py b/tests/snapshots/test_ui_snapshot_feedback_bar.py index b6c0487..f055be3 100644 --- a/tests/snapshots/test_ui_snapshot_feedback_bar.py +++ b/tests/snapshots/test_ui_snapshot_feedback_bar.py @@ -14,7 +14,11 @@ from tests.stubs.fake_backend import FakeBackend @pytest.fixture(autouse=True) def _enable_feedback_bar(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( - "vibe.cli.textual_ui.widgets.feedback_bar.FEEDBACK_PROBABILITY", 1 + "vibe.cli.textual_ui.widgets.feedback_bar_manager.FEEDBACK_PROBABILITY", 1 + ) + monkeypatch.setattr( + "vibe.cli.textual_ui.widgets.feedback_bar_manager.MIN_USER_MESSAGES_FOR_FEEDBACK", + 1, ) @@ -33,7 +37,8 @@ class FeedbackBarSnapshotApp(BaseSnapshotTestApp): def test_snapshot_feedback_bar_visible(snap_compare: SnapCompare) -> None: async def run_before(pilot: Pilot) -> None: with patch( - "vibe.cli.textual_ui.widgets.feedback_bar.random.random", return_value=0 + "vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random", + return_value=0, ): await pilot.press(*"Hello") await pilot.press("enter") diff --git a/tests/snapshots/test_ui_snapshot_teleport.py b/tests/snapshots/test_ui_snapshot_teleport.py index 57bec5b..0bfb90f 100644 --- a/tests/snapshots/test_ui_snapshot_teleport.py +++ b/tests/snapshots/test_ui_snapshot_teleport.py @@ -4,7 +4,10 @@ from textual.app import App, ComposeResult from textual.containers import Container from textual.pilot import Pilot +from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway +from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config from tests.snapshots.snap_compare import SnapCompare +from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse from vibe.cli.textual_ui.widgets.question_app import QuestionApp from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage from vibe.core.tools.builtins.ask_user_question import ( @@ -59,7 +62,7 @@ class TeleportMessageAuthCompleteApp(TeleportMessageTestApp): class TeleportMessageStartingWorkflowApp(TeleportMessageTestApp): def on_mount(self) -> None: widget = self.query_one(TeleportMessage) - widget.set_status("Starting Nuage workflow...") + widget.set_status("Starting Vibe Code session...") if widget._spinner_timer: widget._spinner_timer.stop() @@ -120,6 +123,45 @@ class TeleportPushConfirmationMultipleCommitsApp(TeleportPushConfirmationTestApp super().__init__(count=5) +def _teleport_snapshot_config(): + return default_config().model_copy(update={"vibe_code_enabled": True}) + + +class TeleportCommandHelpSnapshotApp(BaseSnapshotTestApp): + def __init__(self, gateway: FakeWhoAmIGateway): + super().__init__(config=_teleport_snapshot_config(), plan_offer_gateway=gateway) + + async def on_mount(self) -> None: + await super().on_mount() + await self._show_help() + + +class TeleportCommandHelpProApp(TeleportCommandHelpSnapshotApp): + def __init__(self): + super().__init__( + FakeWhoAmIGateway( + WhoAmIResponse( + plan_type=WhoAmIPlanType.CHAT, + plan_name="INDIVIDUAL", + prompt_switching_to_pro_plan=False, + ) + ) + ) + + +class TeleportCommandHelpFreeApp(TeleportCommandHelpSnapshotApp): + def __init__(self): + super().__init__( + FakeWhoAmIGateway( + WhoAmIResponse( + plan_type=WhoAmIPlanType.API, + plan_name="FREE", + prompt_switching_to_pro_plan=False, + ) + ) + ) + + def test_snapshot_teleport_status_checking_git(snap_compare: SnapCompare) -> None: async def run_before(pilot: Pilot) -> None: await pilot.pause(0.2) @@ -247,3 +289,29 @@ def test_snapshot_teleport_push_confirmation_cancel_selected( terminal_size=(80, 12), run_before=run_before, ) + + +def test_snapshot_teleport_command_visible_for_pro_account( + snap_compare: SnapCompare, +) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_teleport.py:TeleportCommandHelpProApp", + terminal_size=(120, 48), + run_before=run_before, + ) + + +def test_snapshot_teleport_command_hidden_for_non_pro_account( + snap_compare: SnapCompare, +) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_teleport.py:TeleportCommandHelpFreeApp", + terminal_size=(120, 48), + run_before=run_before, + ) diff --git a/tests/stubs/fake_backend.py b/tests/stubs/fake_backend.py index 9e3ce07..4fd042d 100644 --- a/tests/stubs/fake_backend.py +++ b/tests/stubs/fake_backend.py @@ -95,7 +95,7 @@ class FakeBackend: if self._exception_to_raise: raise self._exception_to_raise - self._requests_messages.append(messages) + self._requests_messages.append(list(messages)) self._requests_extra_headers.append(extra_headers) self._requests_metadata.append(metadata) @@ -123,7 +123,7 @@ class FakeBackend: if self._exception_to_raise: raise self._exception_to_raise - self._requests_messages.append(messages) + self._requests_messages.append(list(messages)) self._requests_extra_headers.append(extra_headers) self._requests_metadata.append(metadata) @@ -145,5 +145,8 @@ class FakeBackend: extra_headers, metadata=None, ) -> int: + self._requests_messages.append(list(messages)) + self._requests_extra_headers.append(extra_headers) + self._requests_metadata.append(metadata) self._count_tokens_calls.append(list(messages)) return self._token_counter(messages) diff --git a/tests/stubs/fake_mcp_registry.py b/tests/stubs/fake_mcp_registry.py index 139a39a..5419308 100644 --- a/tests/stubs/fake_mcp_registry.py +++ b/tests/stubs/fake_mcp_registry.py @@ -23,6 +23,13 @@ class FakeMCPRegistry(MCPRegistry): ) -> dict[str, type[BaseTool]]: return self.get_tools(servers) + def set_tools( + self, servers: list[MCPServer], tools: dict[str, type[BaseTool]] + ) -> None: + 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]] = {} for srv in servers: diff --git a/tests/test_agent_auto_compact.py b/tests/test_agent_auto_compact.py index 1d88a5d..3482ddf 100644 --- a/tests/test_agent_auto_compact.py +++ b/tests/test_agent_auto_compact.py @@ -1,5 +1,9 @@ from __future__ import annotations +import asyncio +from typing import cast +from unittest.mock import AsyncMock, patch + import pytest from tests.conftest import ( @@ -20,6 +24,18 @@ from vibe.core.types import ( ) +def _get_auto_compact_properties( + telemetry_events: list[dict[str, object]], +) -> dict[str, object]: + auto_compact = [ + event + for event in telemetry_events + if event.get("event_name") == "vibe.auto_compact_triggered" + ] + assert len(auto_compact) == 1 + return cast(dict[str, object], auto_compact[0]["properties"]) + + @pytest.mark.asyncio async def test_auto_compact_emits_correct_events(telemetry_events: list[dict]) -> None: backend = FakeBackend([ @@ -29,6 +45,7 @@ async def test_auto_compact_emits_correct_events(telemetry_events: list[dict]) - cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=1)) agent = build_test_agent_loop(config=cfg, backend=backend) agent.stats.context_tokens = 2 + old_session_id = agent.session_id events = [ev async for ev in agent.act("Hello")] @@ -46,12 +63,66 @@ async def test_auto_compact_emits_correct_events(telemetry_events: list[dict]) - assert end.new_context_tokens >= 1 assert final.content == "" - auto_compact = [ - e - for e in telemetry_events - if e.get("event_name") == "vibe.auto_compact_triggered" - ] - assert len(auto_compact) == 1 + properties = _get_auto_compact_properties(telemetry_events) + assert properties["nb_context_tokens_before"] == 2 + assert properties["nb_context_tokens_after"] == end.new_context_tokens + assert properties["auto_compact_threshold"] == 1 + assert properties["status"] == "success" + assert properties["session_id"] == old_session_id + assert properties["parent_session_id"] is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("side_effect", "expected_exception", "match", "expected_status"), + [ + pytest.param( + RuntimeError("boom"), RuntimeError, "boom", "failure", id="failure" + ), + pytest.param( + asyncio.CancelledError(), + asyncio.CancelledError, + None, + "cancelled", + id="cancelled", + ), + ], +) +async def test_auto_compact_emits_terminal_telemetry( + side_effect: BaseException, + expected_exception: type[BaseException], + match: str | None, + expected_status: str, + telemetry_events: list[dict], +) -> None: + backend = FakeBackend([[mock_llm_chunk(content="")]]) + cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=1)) + agent = build_test_agent_loop(config=cfg, backend=backend) + agent.stats.context_tokens = 2 + old_session_id = agent.session_id + + events = [] + with patch.object(agent, "compact", AsyncMock(side_effect=side_effect)): + if match is None: + with pytest.raises(expected_exception): + async for event in agent.act("Hello"): + events.append(event) + else: + with pytest.raises(expected_exception, match=match): + async for event in agent.act("Hello"): + events.append(event) + + assert len(events) == 2 + assert isinstance(events[0], UserMessageEvent) + assert isinstance(events[1], CompactStartEvent) + + properties = _get_auto_compact_properties(telemetry_events) + assert properties["nb_context_tokens_before"] == 2 + assert properties["nb_context_tokens_after"] == 2 + assert properties["auto_compact_threshold"] == 1 + assert properties["status"] == expected_status + assert properties["session_id"] == old_session_id + assert properties["parent_session_id"] is None @pytest.mark.asyncio @@ -179,3 +250,34 @@ async def test_compact_uses_active_model_when_no_compaction_model() -> None: active = cfg.get_active_model() assert backend.requested_models[0].name == active.name assert backend.requested_models[1].name == active.name + + +@pytest.mark.asyncio +async def test_compact_appends_extra_instructions_to_prompt() -> None: + backend = FakeBackend([[mock_llm_chunk(content="")]]) + cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=999)) + agent = build_test_agent_loop(config=cfg, backend=backend) + agent.messages.append(LLMMessage(role=Role.user, content="Hello")) + agent.stats.context_tokens = 100 + + await agent.compact(extra_instructions="focus on auth") + + compaction_prompt = backend.requests_messages[0][-1].content + assert compaction_prompt is not None + assert "## Additional Instructions" in compaction_prompt + assert "focus on auth" in compaction_prompt + + +@pytest.mark.asyncio +async def test_compact_without_extra_instructions_has_no_additional_section() -> None: + backend = FakeBackend([[mock_llm_chunk(content="")]]) + cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=999)) + agent = build_test_agent_loop(config=cfg, backend=backend) + agent.messages.append(LLMMessage(role=Role.user, content="Hello")) + agent.stats.context_tokens = 100 + + await agent.compact() + + compaction_prompt = backend.requests_messages[0][-1].content + assert compaction_prompt is not None + assert "## Additional Instructions" not in compaction_prompt diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py index ef7bf01..68af45b 100644 --- a/tests/test_agent_backend.py +++ b/tests/test_agent_backend.py @@ -19,8 +19,9 @@ from tests.mock.utils import mock_llm_chunk from tests.stubs.fake_backend import FakeBackend from vibe.core.agents.models import BuiltinAgentName from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig +from vibe.core.telemetry.types import EntrypointMetadata from vibe.core.tools.base import BaseToolConfig, ToolPermission -from vibe.core.types import Backend, EntrypointMetadata, FunctionCall, ToolCall +from vibe.core.types import Backend, FunctionCall, Role, ToolCall def _two_model_vibe_config(active_model: str) -> VibeConfig: @@ -128,12 +129,33 @@ async def test_passes_session_id_to_backend(vibe_config: VibeConfig): meta = backend.requests_metadata[0] assert meta is not None assert meta["session_id"] == agent.session_id + assert "parent_session_id" not in meta assert "message_id" in meta - assert meta["is_user_prompt"] == "true" assert meta["call_type"] == "main_call" assert meta["call_source"] == "vibe_code" +@pytest.mark.asyncio +async def test_passes_parent_session_id_to_backend_after_reset(vibe_config: VibeConfig): + backend = FakeBackend([ + [mock_llm_chunk(content="Response")], + [mock_llm_chunk(content="Response after reset")], + ]) + agent = build_test_agent_loop(config=vibe_config, backend=backend) + + [_ async for _ in agent.act("Hello")] + first_session_id = agent.session_id + + agent._reset_session() + [_ async for _ in agent.act("Hello again")] + + assert len(backend.requests_metadata) >= 2 + reset_meta = backend.requests_metadata[1] + assert reset_meta is not None + assert reset_meta["session_id"] == agent.session_id + assert reset_meta["parent_session_id"] == first_session_id + + @pytest.mark.asyncio async def test_passes_entrypoint_metadata_to_backend(vibe_config: VibeConfig): metadata = EntrypointMetadata( @@ -161,7 +183,6 @@ async def test_passes_entrypoint_metadata_to_backend(vibe_config: VibeConfig): assert meta["client_version"] == "0.5.0" assert meta["session_id"] == agent.session_id assert "message_id" in meta - assert meta["is_user_prompt"] == "true" assert meta["call_type"] == "main_call" assert meta["call_source"] == "vibe_code" @@ -215,6 +236,52 @@ async def test_mcp_sampling_handler_uses_updated_config_when_agent_config_change assert result2.model == "devstral-small-latest" +@pytest.mark.asyncio +async def test_mcp_sampling_handler_sends_secondary_call_telemetry_metadata(): + metadata = EntrypointMetadata( + agent_entrypoint="acp", + agent_version="2.0.0", + client_name="vibe_ide", + client_version="0.5.0", + ) + backend = FakeBackend([ + [mock_llm_chunk(content="Response")], + [mock_llm_chunk(content="Sampled response")], + ]) + agent = build_test_agent_loop( + config=_two_model_vibe_config("devstral-latest"), + backend=backend, + entrypoint_metadata=metadata, + ) + + [_ async for _ in agent.act("Hello")] + + agent.parent_session_id = "parent-session-456" + + result = await agent._sampling_handler(MagicMock(), _make_sampling_params()) + + assert isinstance(result, CreateMessageResult) + assert len(backend.requests_metadata) == 2 + sampling_metadata = backend.requests_metadata[1] + assert sampling_metadata is not None + assert sampling_metadata["agent_entrypoint"] == "acp" + assert sampling_metadata["agent_version"] == "2.0.0" + assert sampling_metadata["client_name"] == "vibe_ide" + assert sampling_metadata["client_version"] == "0.5.0" + assert sampling_metadata["session_id"] == agent.session_id + assert sampling_metadata["parent_session_id"] == "parent-session-456" + assert sampling_metadata["message_id"] == next( + message.message_id for message in agent.messages if message.role == Role.user + ) + assert sampling_metadata["call_type"] == "secondary_call" + assert sampling_metadata["call_source"] == "vibe_code" + + assert len(backend.requests_extra_headers) == 2 + sampling_headers = backend.requests_extra_headers[1] + assert sampling_headers is not None + assert sampling_headers["x-affinity"] == agent.session_id + + def _generic_provider_vibe_config() -> VibeConfig: """VibeConfig with generic backend so no metadata header is sent.""" providers = [ @@ -229,8 +296,8 @@ def _generic_provider_vibe_config() -> VibeConfig: @pytest.mark.asyncio -async def test_mistral_metadata_header_is_user_prompt_per_turn() -> None: - """First LLM call in a turn has is_user_prompt=True; second call (after tools) has is_user_prompt=False.""" +async def test_mistral_metadata_header_call_type_per_turn() -> None: + """First LLM call in a turn is main_call; second call (after tools) is secondary_call.""" tool_call = ToolCall( id="call_1", index=0, @@ -261,19 +328,15 @@ async def test_mistral_metadata_header_is_user_prompt_per_turn() -> None: assert len(backend.requests_metadata) == 2 first_metadata = backend.requests_metadata[0] second_metadata = backend.requests_metadata[1] - assert first_metadata is not None and "is_user_prompt" in first_metadata - assert second_metadata is not None and "is_user_prompt" in second_metadata - assert first_metadata["is_user_prompt"] == "true" - assert second_metadata["is_user_prompt"] == "false" + assert first_metadata is not None + assert second_metadata is not None assert first_metadata["call_type"] == "main_call" assert second_metadata["call_type"] == "secondary_call" @pytest.mark.asyncio -async def test_auto_compact_internal_chat_has_is_user_prompt_false_then_user_turn_true() -> ( - None -): - """Compact's internal _chat() sends is_user_prompt=False; the following user turn sends is_user_prompt=True.""" +async def test_auto_compact_emits_summary_recount_and_next_turn_metadata() -> None: + """Compact emits summary, token-count, then user-turn backend metadata in order.""" backend = FakeBackend([ [mock_llm_chunk(content="")], [mock_llm_chunk(content="")], @@ -291,18 +354,37 @@ async def test_auto_compact_internal_chat_has_is_user_prompt_false_then_user_tur ) agent = build_test_agent_loop(config=config, backend=backend) agent.stats.context_tokens = 2 + original_session_id = agent.session_id [_ async for _ in agent.act("Hello")] - assert len(backend.requests_metadata) == 2 + assert len(backend.requests_metadata) == 3 + assert len(backend.requests_extra_headers) == 3 compact_metadata = backend.requests_metadata[0] - user_turn_metadata = backend.requests_metadata[1] - assert compact_metadata is not None and "is_user_prompt" in compact_metadata - assert user_turn_metadata is not None and "is_user_prompt" in user_turn_metadata - assert compact_metadata["is_user_prompt"] == "false" - assert user_turn_metadata["is_user_prompt"] == "true" + recount_metadata = backend.requests_metadata[1] + user_turn_metadata = backend.requests_metadata[2] + assert compact_metadata is not None + assert recount_metadata is not None + assert user_turn_metadata is not None assert compact_metadata["call_type"] == "secondary_call" + assert compact_metadata["session_id"] == original_session_id + assert "parent_session_id" not in compact_metadata + assert recount_metadata["call_type"] == "secondary_call" + assert recount_metadata["session_id"] == agent.session_id + assert recount_metadata["parent_session_id"] == original_session_id assert user_turn_metadata["call_type"] == "main_call" + assert user_turn_metadata["session_id"] == agent.session_id + assert user_turn_metadata["parent_session_id"] == original_session_id + + compact_headers = backend.requests_extra_headers[0] + recount_headers = backend.requests_extra_headers[1] + user_turn_headers = backend.requests_extra_headers[2] + assert compact_headers is not None + assert recount_headers is not None + assert user_turn_headers is not None + assert compact_headers["x-affinity"] == original_session_id + assert recount_headers["x-affinity"] == agent.session_id + assert user_turn_headers["x-affinity"] == agent.session_id @pytest.mark.asyncio diff --git a/tests/test_agent_observer_streaming.py b/tests/test_agent_observer_streaming.py index 554fed8..d1a43e2 100644 --- a/tests/test_agent_observer_streaming.py +++ b/tests/test_agent_observer_streaming.py @@ -15,7 +15,7 @@ from tests.mock.utils import mock_llm_chunk from tests.stubs.fake_backend import FakeBackend from vibe.core.agents.models import BuiltinAgentName from vibe.core.config import VibeConfig -from vibe.core.llm.exceptions import BackendErrorBuilder +from vibe.core.llm.exceptions import BackendError, BackendErrorBuilder from vibe.core.middleware import ( ConversationContext, MiddlewareAction, @@ -26,6 +26,7 @@ from vibe.core.tools.builtins.todo import TodoArgs from vibe.core.types import ( ApprovalResponse, AssistantEvent, + ContextTooLongError, FunctionCall, LLMMessage, RateLimitError, @@ -474,6 +475,67 @@ async def test_rate_limit(observer_capture) -> None: assert agent.session_logger.save_interaction.await_count == 1 +def _build_context_too_long_backend_error() -> BackendError: + response = httpx.Response( + HTTPStatus.BAD_REQUEST, + request=httpx.Request("POST", "http://test"), + text='{"message": "Context too long"}', + ) + error = httpx.HTTPStatusError( + "context too long", request=response.request, response=response + ) + return BackendErrorBuilder.build_http_error( + provider="mistral", + endpoint="test", + error=error, + model="test-model", + messages=[], + temperature=0.0, + has_tools=False, + tool_choice=None, + ) + + +@pytest.mark.asyncio +async def test_context_too_long_streaming(observer_capture) -> None: + observed, observer = observer_capture + backend_error = _build_context_too_long_backend_error() + backend = FakeBackend(exception_to_raise=backend_error) + agent = build_test_agent_loop( + config=make_config(), + backend=backend, + message_observer=observer, + enable_streaming=True, + ) + agent.session_logger.save_interaction = AsyncMock(return_value=None) + + with pytest.raises(ContextTooLongError): + [_ async for _ in agent.act("Trigger context too long while streaming")] + + assert [role for role, _ in observed] == [Role.system, Role.user] + assert agent.session_logger.save_interaction.await_count == 1 + + +@pytest.mark.asyncio +async def test_context_too_long_non_streaming(observer_capture) -> None: + observed, observer = observer_capture + backend_error = _build_context_too_long_backend_error() + backend = FakeBackend(exception_to_raise=backend_error) + agent = build_test_agent_loop( + config=make_config(), + backend=backend, + message_observer=observer, + enable_streaming=False, + ) + agent.session_logger.save_interaction = AsyncMock(return_value=None) + + with pytest.raises(ContextTooLongError): + [_ async for _ in agent.act("Trigger context too long without streaming")] + + assert [role for role, _ in observed] == [Role.system, Role.user] + assert agent.session_logger.save_interaction.await_count == 1 + + def _snapshot_events(events: list) -> list[tuple[str, str]]: return [ (type(e).__name__, e.content) diff --git a/tests/test_agent_tool_call.py b/tests/test_agent_tool_call.py index fc9d216..91cdc3a 100644 --- a/tests/test_agent_tool_call.py +++ b/tests/test_agent_tool_call.py @@ -339,7 +339,7 @@ async def test_approval_always_sets_tool_permission_for_subsequent_calls() -> No assert tool_config_todo.permission is ToolPermission.ALWAYS tool_config_help = agent_loop.tool_manager.get_tool_config("bash") assert tool_config_help.permission is not ToolPermission.ALWAYS - assert agent_loop.auto_approve is False + assert agent_loop.bypass_tool_permissions is False assert len(callback_invocations) == 1 assert callback_invocations[0] == "todo" assert isinstance(events1[0], UserMessageEvent) diff --git a/tests/test_agents.py b/tests/test_agents.py index d8f0731..0bac5ee 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -230,15 +230,49 @@ class TestAgentApplyToConfig: assert result.system_prompt_id == "cc" assert result.system_prompt == "Global custom prompt" + def test_custom_prompt_overrides_builtin( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Custom prompts in .vibe/prompts/ should override built-in prompts. + + A user-provided explore.md (or any built-in prompt name) in the + project or user prompts directory must take priority over the + bundled SystemPrompt enum. + """ + project_prompts = tmp_path / "project" / ".vibe" / "prompts" + project_prompts.mkdir(parents=True) + (project_prompts / "explore.md").write_text("My custom explore prompt") + + class _MockManager(HarnessFilesManager): + @property + def project_prompts_dirs(self) -> list[Path]: + return [project_prompts] + + @property + def user_prompts_dirs(self) -> list[Path]: + return [] + + mock_manager = _MockManager(sources=("user",)) + monkeypatch.setattr( + "vibe.core.config._settings.get_harness_files_manager", lambda: mock_manager + ) + + config = VibeConfig( + system_prompt_id="explore", + include_project_context=False, + include_prompt_detail=False, + ) + assert config.system_prompt == "My custom explore prompt" + class TestAgentProfileOverrides: def test_default_agent_disables_exit_plan_mode(self) -> None: overrides = BUILTIN_AGENTS[BuiltinAgentName.DEFAULT].overrides assert "exit_plan_mode" in overrides.get("base_disabled", []) - def test_auto_approve_agent_sets_auto_approve(self) -> None: + def test_auto_approve_agent_sets_bypass_tool_permissions(self) -> None: overrides = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].overrides - assert overrides.get("auto_approve") is True + assert overrides.get("bypass_tool_permissions") is True def test_plan_agent_restricts_tools(self) -> None: overrides = BUILTIN_AGENTS[BuiltinAgentName.PLAN].overrides diff --git a/tests/test_cli_programmatic_preload.py b/tests/test_cli_programmatic_preload.py index 7a71cd9..9777436 100644 --- a/tests/test_cli_programmatic_preload.py +++ b/tests/test_cli_programmatic_preload.py @@ -163,7 +163,7 @@ def test_run_programmatic_teleport_ignored_when_nuage_disabled( include_prompt_detail=False, include_model_info=False, include_commit_signature=False, - nuage_enabled=False, + vibe_code_enabled=False, ) run_programmatic( diff --git a/tests/test_reasoning_content.py b/tests/test_reasoning_content.py index e5108a8..bcaac5b 100644 --- a/tests/test_reasoning_content.py +++ b/tests/test_reasoning_content.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from unittest.mock import MagicMock import httpx @@ -260,13 +261,14 @@ class TestGenericBackendReasoningContent: class TestAPIToolFormatHandlerReasoningContent: - def test_process_api_response_message_extracts_reasoning_content(self): + def test_process_api_response_message_preserves_reasoning_state_for_history(self): handler = APIToolFormatHandler() mock_message = MagicMock() mock_message.role = "assistant" mock_message.content = "The answer is 42." mock_message.reasoning_content = "Let me think..." + mock_message.reasoning_state = ["enc:abc"] mock_message.reasoning_signature = None mock_message.tool_calls = None @@ -274,6 +276,7 @@ class TestAPIToolFormatHandlerReasoningContent: assert result.content == "The answer is 42." assert result.reasoning_content == "Let me think..." + assert result.reasoning_state == ["enc:abc"] def test_process_api_response_message_handles_missing_reasoning_content(self): handler = APIToolFormatHandler() @@ -287,6 +290,7 @@ class TestAPIToolFormatHandlerReasoningContent: assert result.content == "Hello" assert result.reasoning_content is None + assert result.reasoning_state is None class TestAgentLoopStreamingReasoningEvents: @@ -351,6 +355,15 @@ class TestLLMMessageReasoningContent: assert dumped["reasoning_content"] == "Thinking..." + def test_llm_message_model_dump_includes_reasoning_state(self): + msg = LLMMessage( + role=Role.assistant, content="Answer", reasoning_state=["enc:abc"] + ) + + dumped = msg.model_dump(exclude_none=True) + + assert dumped["reasoning_state"] == ["enc:abc"] + def test_llm_message_model_dump_excludes_none_reasoning_content(self): msg = LLMMessage(role=Role.assistant, content="Answer") @@ -411,6 +424,37 @@ class TestReasoningFieldNameConversion: assert result["reasoning_content"] == "Thinking..." + def test_prepare_request_excludes_reasoning_state_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.assistant, + content="Answer", + reasoning_content="Thinking...", + reasoning_state=["enc:abc"], + ) + ], + 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]["reasoning_content"] == "Thinking..." + assert "reasoning_state" not in payload["messages"][0] + @pytest.mark.asyncio async def test_complete_with_custom_reasoning_field_name(self): base_url = "https://api.example.com" diff --git a/tests/test_system_prompt.py b/tests/test_system_prompt.py index 95cba21..3252920 100644 --- a/tests/test_system_prompt.py +++ b/tests/test_system_prompt.py @@ -6,6 +6,7 @@ import pytest from tests.conftest import build_test_vibe_config from vibe.core.agents import AgentManager +from vibe.core.scratchpad import init_scratchpad from vibe.core.skills.manager import SkillManager from vibe.core.system_prompt import get_universal_system_prompt from vibe.core.tools.manager import ToolManager @@ -42,3 +43,83 @@ def test_get_universal_system_prompt_includes_windows_prompt_on_windows( assert "Use: backslashes (\\\\) for paths" in prompt assert "Check command availability with: `where command` (Windows)" in prompt assert "Script shebang: Not applicable on Windows" in prompt + + +def test_scratchpad_section_included_when_passed() -> None: + sp = init_scratchpad("test-session") + config = build_test_vibe_config( + system_prompt_id="tests", + include_project_context=False, + include_prompt_detail=True, + include_model_info=False, + include_commit_signature=False, + ) + tool_manager = ToolManager(lambda: config) + skill_manager = SkillManager(lambda: config) + agent_manager = AgentManager(lambda: config) + + prompt = get_universal_system_prompt( + tool_manager, config, skill_manager, agent_manager, scratchpad_dir=sp + ) + + assert "# Scratchpad Directory" in prompt + assert sp is not None + assert str(sp) in prompt + + +def test_scratchpad_section_absent_when_not_passed() -> None: + config = build_test_vibe_config( + system_prompt_id="tests", + include_project_context=False, + include_prompt_detail=True, + include_model_info=False, + include_commit_signature=False, + ) + tool_manager = ToolManager(lambda: config) + skill_manager = SkillManager(lambda: config) + agent_manager = AgentManager(lambda: config) + + prompt = get_universal_system_prompt( + tool_manager, config, skill_manager, agent_manager + ) + + assert "Scratchpad Directory" not in prompt + + +def test_headless_section_included_when_enabled() -> None: + config = build_test_vibe_config( + system_prompt_id="tests", + include_project_context=False, + include_prompt_detail=False, + include_model_info=False, + include_commit_signature=False, + ) + tool_manager = ToolManager(lambda: config) + skill_manager = SkillManager(lambda: config) + agent_manager = AgentManager(lambda: config) + + prompt = get_universal_system_prompt( + tool_manager, config, skill_manager, agent_manager, headless=True + ) + + assert "# Headless Mode" in prompt + assert "no human is available to respond" in prompt + + +def test_headless_section_absent_by_default() -> None: + config = build_test_vibe_config( + system_prompt_id="tests", + include_project_context=False, + include_prompt_detail=False, + include_model_info=False, + include_commit_signature=False, + ) + tool_manager = ToolManager(lambda: config) + skill_manager = SkillManager(lambda: config) + agent_manager = AgentManager(lambda: config) + + prompt = get_universal_system_prompt( + tool_manager, config, skill_manager, agent_manager + ) + + assert "Headless Mode" not in prompt diff --git a/tests/test_turn_summary.py b/tests/test_turn_summary.py index 0184f39..1f1a1ef 100644 --- a/tests/test_turn_summary.py +++ b/tests/test_turn_summary.py @@ -173,6 +173,77 @@ class TestTurnSummaryTracker: assert summary_msgs[1].content is not None assert "do something" in summary_msgs[1].content + @pytest.mark.asyncio + async def test_end_turn_sends_secondary_call_metadata(self): + backend = FakeBackend(mock_llm_chunk(content="summary")) + tracker = TurnSummaryTracker( + backend=backend, + model=_TEST_MODEL, + session_metadata_getter=lambda: { + "agent_entrypoint": "cli", + "agent_version": "1.0.0", + "client_name": "vibe_cli", + "client_version": "1.0.0", + "session_id": "session-123", + }, + ) + + tracker.start_turn("hello") + tracker.end_turn() + await asyncio.sleep(0.1) + + metadata = backend.requests_metadata[0] + assert metadata is not None + assert metadata["agent_entrypoint"] == "cli" + assert metadata["agent_version"] == "1.0.0" + assert metadata["client_name"] == "vibe_cli" + assert metadata["client_version"] == "1.0.0" + assert metadata["session_id"] == "session-123" + assert "parent_session_id" not in metadata + assert metadata["call_source"] == "vibe_code" + assert metadata["call_type"] == "secondary_call" + + @pytest.mark.asyncio + async def test_end_turn_sends_message_id_when_user_message_event_tracked(self): + backend = FakeBackend(mock_llm_chunk(content="summary")) + tracker = TurnSummaryTracker( + backend=backend, + model=_TEST_MODEL, + session_metadata_getter=lambda: {"session_id": "session-123"}, + ) + + tracker.start_turn("hello") + tracker.track(UserMessageEvent(content="hello", message_id="message-456")) + tracker.end_turn() + await asyncio.sleep(0.1) + + metadata = backend.requests_metadata[0] + assert metadata is not None + assert metadata["message_id"] == "message-456" + assert metadata["call_type"] == "secondary_call" + + @pytest.mark.asyncio + async def test_end_turn_sends_parent_session_id_when_present(self): + backend = FakeBackend(mock_llm_chunk(content="summary")) + tracker = TurnSummaryTracker( + backend=backend, + model=_TEST_MODEL, + session_metadata_getter=lambda: { + "session_id": "session-123", + "parent_session_id": "parent-session-456", + }, + ) + + tracker.start_turn("hello") + tracker.end_turn() + await asyncio.sleep(0.1) + + metadata = backend.requests_metadata[0] + assert metadata is not None + assert metadata["session_id"] == "session-123" + assert metadata["parent_session_id"] == "parent-session-456" + assert metadata["call_type"] == "secondary_call" + @pytest.mark.asyncio async def test_end_turn_clears_state(self): backend = FakeBackend(mock_llm_chunk(content="summary")) diff --git a/tests/tools/test_bash.py b/tests/tools/test_bash.py index 6faaaae..8c50f12 100644 --- a/tests/tools/test_bash.py +++ b/tests/tools/test_bash.py @@ -77,15 +77,53 @@ async def test_decodes_non_utf8_bytes(bash): assert result.stderr == "" -def test_find_not_in_default_allowlist(): - bash_tool = Bash(config_getter=lambda: BashToolConfig(), state=BaseToolState()) - # find -exec runs arbitrary commands; must not be allowlisted by default - permission = bash_tool.resolve_permission(BashArgs(command="find . -exec id \\;")) - assert ( - not isinstance(permission, PermissionContext) - or permission.permission is not ToolPermission.ALWAYS +@pytest.mark.parametrize("predicate", ["-exec", "-execdir", "-ok", "-okdir"]) +def test_find_execution_predicates_force_ask(predicate: str): + config = BashToolConfig(permission=ToolPermission.ALWAYS) + bash_tool = Bash(config_getter=lambda: config, state=BaseToolState()) + + permission = bash_tool.resolve_permission( + BashArgs(command=f"find . {predicate} id \\;") ) + assert isinstance(permission, PermissionContext) + assert permission.permission is ToolPermission.ASK + assert [required.label for required in permission.required_permissions] == [ + f"find . {predicate} id \\;" + ] + + +def test_find_exec_compound_includes_companion_required_permission(): + config = BashToolConfig(permission=ToolPermission.ALWAYS) + bash_tool = Bash(config_getter=lambda: config, state=BaseToolState()) + + permission = bash_tool.resolve_permission( + BashArgs(command='find . -exec id \\; && python3 -c "import os"') + ) + + assert isinstance(permission, PermissionContext) + assert permission.permission is ToolPermission.ASK + labels = {rp.label for rp in permission.required_permissions} + assert any("find" in label for label in labels), ( + f"Expected a find-exec RequiredPermission, got {labels}" + ) + assert any("python3" in label for label in labels), ( + f"Companion command should also require permission, got {labels}" + ) + + +def test_find_execution_predicate_does_not_override_denylist(): + config = BashToolConfig(denylist=["passwd"]) + bash_tool = Bash(config_getter=lambda: config, state=BaseToolState()) + + permission = bash_tool.resolve_permission( + BashArgs(command="find . -exec id \\; && passwd root") + ) + + assert isinstance(permission, PermissionContext) + assert permission.permission is ToolPermission.NEVER + assert "matches denylist pattern 'passwd'" in (permission.reason or "") + def test_resolve_permission(): config = BashToolConfig(allowlist=["echo", "pwd"], denylist=["rm"]) diff --git a/tests/tools/test_connectors.py b/tests/tools/test_connectors.py index c569f36..fa7b6ee 100644 --- a/tests/tools/test_connectors.py +++ b/tests/tools/test_connectors.py @@ -9,7 +9,7 @@ import pytest from tests.stubs.fake_connector_registry import FakeConnectorRegistry from tests.stubs.fake_mcp_registry import FakeMCPRegistry -from vibe.core.config import VibeConfig +from vibe.core.config import ConnectorConfig, VibeConfig from vibe.core.tools.base import BaseToolConfig, ToolError from vibe.core.tools.connectors import CONNECTORS_ENV_VAR from vibe.core.tools.connectors.connector_registry import ( @@ -150,7 +150,7 @@ class TestFakeConnectorRegistry: class TestToolManagerConnectorIntegration: @staticmethod - def _make_config() -> VibeConfig: + def _make_config(connectors: list[ConnectorConfig] | None = None) -> VibeConfig: """Minimal VibeConfig-like stub for ToolManager.""" return cast( VibeConfig, @@ -159,6 +159,7 @@ class TestToolManagerConnectorIntegration: (), { "mcp_servers": [], + "connectors": connectors or [], "enabled_tools": [], "disabled_tools": [], "tools": {}, @@ -399,3 +400,100 @@ class TestConnectorProxyToolRun: pytest.raises(ToolError, match="network"), ): [_ async for _ in tool.invoke(query="hello")] + + +# --------------------------------------------------------------------------- +# ToolManager: per-connector disabled / disabled_tools filtering +# --------------------------------------------------------------------------- + + +class TestConnectorDisableFiltering: + @staticmethod + def _make_config(connectors: list[ConnectorConfig] | None = None) -> VibeConfig: + return cast( + VibeConfig, + type( + "_Cfg", + (), + { + "mcp_servers": [], + "connectors": connectors or [], + "enabled_tools": [], + "disabled_tools": [], + "tools": {}, + "tool_paths": [], + }, + )(), + ) + + def test_disabled_connector_excludes_all_tools(self) -> None: + registry = FakeConnectorRegistry( + connectors={ + "wiki": [ + RemoteTool(name="search", description="Search"), + RemoteTool(name="read", description="Read"), + ] + } + ) + config = self._make_config( + connectors=[ConnectorConfig(name="wiki", disabled=True)] + ) + tm = ToolManager( + config_getter=lambda: config, + mcp_registry=FakeMCPRegistry(), + connector_registry=registry, + ) + assert "connector_wiki_search" not in tm.available_tools + assert "connector_wiki_read" not in tm.available_tools + # But still registered (discoverable for UI) + assert "connector_wiki_search" in tm.registered_tools + + def test_disabled_tools_filters_specific_tools(self) -> None: + registry = FakeConnectorRegistry( + connectors={ + "mail": [ + RemoteTool(name="send", description="Send"), + RemoteTool(name="read", description="Read"), + ] + } + ) + config = self._make_config( + connectors=[ConnectorConfig(name="mail", disabled_tools=["send"])] + ) + tm = ToolManager( + config_getter=lambda: config, + mcp_registry=FakeMCPRegistry(), + connector_registry=registry, + ) + assert "connector_mail_send" not in tm.available_tools + assert "connector_mail_read" in tm.available_tools + + def test_no_config_means_all_enabled(self) -> None: + registry = FakeConnectorRegistry( + connectors={"wiki": [RemoteTool(name="search", description="Search")]} + ) + config = self._make_config(connectors=[]) + tm = ToolManager( + config_getter=lambda: config, + mcp_registry=FakeMCPRegistry(), + connector_registry=registry, + ) + assert "connector_wiki_search" in tm.available_tools + + def test_unrelated_config_does_not_affect_other_connectors(self) -> None: + registry = FakeConnectorRegistry( + connectors={ + "wiki": [RemoteTool(name="search", description="Search")], + "mail": [RemoteTool(name="send", description="Send")], + } + ) + config = self._make_config( + connectors=[ConnectorConfig(name="mail", disabled=True)] + ) + tm = ToolManager( + config_getter=lambda: config, + mcp_registry=FakeMCPRegistry(), + connector_registry=registry, + ) + assert "connector_wiki_search" in tm.available_tools + assert "connector_mail_send" not in tm.available_tools diff --git a/tests/tools/test_granular_permissions.py b/tests/tools/test_granular_permissions.py index 6450646..6a29a73 100644 --- a/tests/tools/test_granular_permissions.py +++ b/tests/tools/test_granular_permissions.py @@ -185,6 +185,12 @@ class TestBashGranularPermissions: assert isinstance(result, PermissionContext) assert result.permission is ToolPermission.ASK + def test_sensitive_bypasses_global_always_permission(self): + bash = self._bash(permission=ToolPermission.ALWAYS) + result = bash.resolve_permission(BashArgs(command="sudo ls")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ASK + def test_allowlisted_outside_dir_still_asks(self): bash = self._bash() # cat is allowlisted but /etc/passwd is outside workdir diff --git a/tests/tools/test_mcp.py b/tests/tools/test_mcp.py index ebe690d..558f111 100644 --- a/tests/tools/test_mcp.py +++ b/tests/tools/test_mcp.py @@ -10,7 +10,8 @@ from unittest.mock import AsyncMock, MagicMock, patch from pydantic import ValidationError import pytest -from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp +from tests.stubs.fake_mcp_registry import FakeMCPRegistry +from vibe.core.config import MCPHttp, MCPStdio, MCPStreamableHttp, VibeConfig from vibe.core.tools.mcp import ( MCPRegistry, MCPToolResult, @@ -710,3 +711,140 @@ class TestMCPStdioCwd: ) assert proxy_cls._cwd is None + + +# --------------------------------------------------------------------------- +# _MCPBase disabled / disabled_tools field tests +# --------------------------------------------------------------------------- + + +class TestMCPBaseDisableFields: + def test_disabled_defaults_to_false(self): + config = MCPStdio(name="test", transport="stdio", command="python") + assert config.disabled is False + assert config.disabled_tools == [] + + def test_disabled_true(self): + config = MCPStdio( + name="test", transport="stdio", command="python", disabled=True + ) + assert config.disabled is True + + def test_disabled_tools_list(self): + config = MCPHttp( + name="test", + transport="http", + url="http://localhost:8080", + disabled_tools=["search", "read"], + ) + assert config.disabled_tools == ["search", "read"] + + def test_disabled_fields_on_streamable_http(self): + config = MCPStreamableHttp( + name="test", + transport="streamable-http", + url="http://localhost:8080", + disabled=True, + disabled_tools=["write"], + ) + assert config.disabled is True + assert config.disabled_tools == ["write"] + + +# --------------------------------------------------------------------------- +# ToolManager: per-MCP-server disabled / disabled_tools filtering +# --------------------------------------------------------------------------- + +from vibe.core.tools.manager import ToolManager + + +class TestMCPDisableFiltering: + @staticmethod + def _make_config( + mcp_servers: list[MCPHttp | MCPStdio | MCPStreamableHttp] | None = None, + ) -> VibeConfig: + return cast( + VibeConfig, + type( + "_Cfg", + (), + { + "mcp_servers": mcp_servers or [], + "connectors": [], + "enabled_tools": [], + "disabled_tools": [], + "tools": {}, + "tool_paths": [], + }, + )(), + ) + + def test_disabled_server_excludes_all_tools(self): + srv = MCPHttp( + name="demo", transport="http", url="http://demo:9090", disabled=True + ) + registry = FakeMCPRegistry() + remote_a = RemoteTool(name="tool_a", description="A") + remote_b = RemoteTool(name="tool_b", description="B") + proxy_a = create_mcp_http_proxy_tool_class( + url="http://demo:9090", remote=remote_a, alias="demo" + ) + proxy_b = create_mcp_http_proxy_tool_class( + url="http://demo:9090", remote=remote_b, alias="demo" + ) + registry.set_tools( + [srv], {proxy_a.get_name(): proxy_a, proxy_b.get_name(): proxy_b} + ) + + config = self._make_config(mcp_servers=[srv]) + tm = ToolManager( + config_getter=lambda: config, mcp_registry=registry, connector_registry=None + ) + assert "demo_tool_a" not in tm.available_tools + assert "demo_tool_b" not in tm.available_tools + # Still registered (discoverable for UI) + assert "demo_tool_a" in tm.registered_tools + + def test_disabled_tools_filters_specific_tools(self): + srv = MCPHttp( + name="demo", + transport="http", + url="http://demo:9090", + disabled_tools=["tool_a"], + ) + registry = FakeMCPRegistry() + remote_a = RemoteTool(name="tool_a", description="A") + remote_b = RemoteTool(name="tool_b", description="B") + proxy_a = create_mcp_http_proxy_tool_class( + url="http://demo:9090", remote=remote_a, alias="demo" + ) + proxy_b = create_mcp_http_proxy_tool_class( + url="http://demo:9090", remote=remote_b, alias="demo" + ) + registry.set_tools( + [srv], {proxy_a.get_name(): proxy_a, proxy_b.get_name(): proxy_b} + ) + + config = self._make_config(mcp_servers=[srv]) + tm = ToolManager( + config_getter=lambda: config, mcp_registry=registry, connector_registry=None + ) + assert "demo_tool_a" not in tm.available_tools + assert "demo_tool_b" in tm.available_tools + + def test_disabled_false_is_noop(self): + srv = MCPHttp( + name="demo", transport="http", url="http://demo:9090", disabled=False + ) + registry = FakeMCPRegistry() + remote = RemoteTool(name="tool_a", description="A") + proxy = create_mcp_http_proxy_tool_class( + url="http://demo:9090", remote=remote, alias="demo" + ) + registry.set_tools([srv], {proxy.get_name(): proxy}) + + config = self._make_config(mcp_servers=[srv]) + tm = ToolManager( + config_getter=lambda: config, mcp_registry=registry, connector_registry=None + ) + assert "demo_tool_a" in tm.available_tools diff --git a/tests/tools/test_mcp_sampling.py b/tests/tools/test_mcp_sampling.py index ae37710..a2c07b8 100644 --- a/tests/tools/test_mcp_sampling.py +++ b/tests/tools/test_mcp_sampling.py @@ -156,6 +156,23 @@ class TestMCPSamplingHandler: assert sent[0].role == Role.user assert sent[0].content == "Hello" + @pytest.mark.asyncio + async def test_forwards_metadata_and_headers(self) -> None: + chunk = mock_llm_chunk(content="ok") + backend = FakeBackend(chunk) + config = _make_config() + handler = MCPSamplingHandler( + backend_getter=lambda: backend, + config_getter=lambda: config, + metadata_getter=lambda: {"call_type": "secondary_call"}, + extra_headers_getter=lambda: {"x-affinity": "session-123"}, + ) + + await handler(MagicMock(), _make_params()) + + assert backend.requests_metadata == [{"call_type": "secondary_call"}] + assert backend.requests_extra_headers == [{"x-affinity": "session-123"}] + @pytest.mark.asyncio async def test_returns_error_on_backend_failure(self) -> None: backend = FakeBackend(exception_to_raise=RuntimeError("boom")) diff --git a/tests/tools/test_scratchpad_permissions.py b/tests/tools/test_scratchpad_permissions.py new file mode 100644 index 0000000..f73f29c --- /dev/null +++ b/tests/tools/test_scratchpad_permissions.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import pytest + +import vibe.core.scratchpad as scratchpad_mod +from vibe.core.scratchpad import init_scratchpad +from vibe.core.tools.base import BaseToolState, ToolPermission +from vibe.core.tools.builtins.bash import ( + Bash, + BashArgs, + BashToolConfig, + _collect_outside_dirs, +) +from vibe.core.tools.builtins.read_file import ( + ReadFile, + ReadFileArgs, + ReadFileState, + ReadFileToolConfig, +) +from vibe.core.tools.builtins.write_file import ( + WriteFile, + WriteFileArgs, + WriteFileConfig, +) +from vibe.core.tools.permissions import PermissionContext, PermissionScope + + +@pytest.fixture(autouse=True) +def _setup_scratchpad(): + init_scratchpad("test-session") + + +class TestFileToolScratchpadPermissions: + def test_write_file_scratchpad_always_allowed(self): + sp = scratchpad_mod.get_scratchpad_dir("test-session") + assert sp is not None + tool = WriteFile(config_getter=lambda: WriteFileConfig(), state=BaseToolState()) + result = tool.resolve_permission( + WriteFileArgs(path=str(sp / "draft.py"), content="x") + ) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ALWAYS + + def test_read_file_scratchpad_always_allowed(self): + sp = scratchpad_mod.get_scratchpad_dir("test-session") + assert sp is not None + tool = ReadFile( + config_getter=lambda: ReadFileToolConfig(), state=ReadFileState() + ) + result = tool.resolve_permission(ReadFileArgs(path=str(sp / "notes.txt"))) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ALWAYS + + def test_scratchpad_env_file_still_allowed(self): + """Scratchpad bypasses sensitive pattern checks.""" + sp = scratchpad_mod.get_scratchpad_dir("test-session") + assert sp is not None + tool = WriteFile(config_getter=lambda: WriteFileConfig(), state=BaseToolState()) + result = tool.resolve_permission( + WriteFileArgs(path=str(sp / ".env"), content="SECRET=x") + ) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ALWAYS + + def test_non_scratchpad_outside_dir_still_asks(self): + tool = WriteFile(config_getter=lambda: WriteFileConfig(), state=BaseToolState()) + result = tool.resolve_permission( + WriteFileArgs(path="/tmp/not-scratchpad/file.txt", content="x") + ) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.ASK + + +class TestBashScratchpadPermissions: + def test_scratchpad_path_not_flagged_as_outside_dir(self): + sp = scratchpad_mod.get_scratchpad_dir("test-session") + assert sp is not None + dirs = _collect_outside_dirs([f"cat {sp}/file.txt"]) + assert len(dirs) == 0 + + def test_non_scratchpad_outside_path_still_flagged(self): + dirs = _collect_outside_dirs(["cat /etc/hosts"]) + assert len(dirs) >= 1 + + def test_bash_scratchpad_mkdir_no_outside_dir_permission(self): + sp = scratchpad_mod.get_scratchpad_dir("test-session") + assert sp is not None + bash = Bash(config_getter=lambda: BashToolConfig(), state=BaseToolState()) + result = bash.resolve_permission(BashArgs(command=f"mkdir {sp}/subdir")) + assert isinstance(result, PermissionContext) + outside = [ + rp + for rp in result.required_permissions + if rp.scope is PermissionScope.OUTSIDE_DIRECTORY + ] + assert len(outside) == 0 diff --git a/tests/update_notifier/test_filesystem_update_cache_repository.py b/tests/update_notifier/test_filesystem_update_cache_repository.py index 4187243..66eec22 100644 --- a/tests/update_notifier/test_filesystem_update_cache_repository.py +++ b/tests/update_notifier/test_filesystem_update_cache_repository.py @@ -2,8 +2,10 @@ from __future__ import annotations import json from pathlib import Path +import tomllib import pytest +import tomli_w from vibe.cli.update_notifier.adapters.filesystem_update_cache_repository import ( FileSystemUpdateCacheRepository, @@ -11,11 +13,21 @@ from vibe.cli.update_notifier.adapters.filesystem_update_cache_repository import from vibe.cli.update_notifier.ports.update_cache_repository import UpdateCache +def _write_cache_toml(base: Path, data: dict) -> None: + with (base / "cache.toml").open("wb") as f: + tomli_w.dump(data, f) + + @pytest.mark.asyncio -async def test_reads_cache_from_file_when_present(tmp_path: Path) -> None: - cache_file = tmp_path / "update_cache.json" - cache_file.write_text( - json.dumps({"latest_version": "1.2.3", "stored_at_timestamp": 1_700_000_000}) +async def test_reads_cache_from_toml_when_present(tmp_path: Path) -> None: + _write_cache_toml( + tmp_path, + { + "update_cache": { + "latest_version": "1.2.3", + "stored_at_timestamp": 1_700_000_000, + } + }, ) repository = FileSystemUpdateCacheRepository(base_path=tmp_path) @@ -28,61 +40,70 @@ async def test_reads_cache_from_file_when_present(tmp_path: Path) -> None: @pytest.mark.asyncio -async def test_returns_none_when_cache_file_is_missing(tmp_path: Path) -> None: - repository = FileSystemUpdateCacheRepository(base_path=tmp_path) - - cache = await repository.get() - - assert cache is None - - -@pytest.mark.asyncio -async def test_returns_none_when_cache_file_is_corrupted(tmp_path: Path) -> None: - cache_dir = tmp_path / ".vibe" - cache_dir.mkdir() - (cache_dir / "update_cache.json").write_text("{not-json") - repository = FileSystemUpdateCacheRepository(base_path=tmp_path) - - cache = await repository.get() - - assert cache is None - - -@pytest.mark.asyncio -async def test_overwrites_existing_cache(tmp_path: Path) -> None: - cache_file = tmp_path / "update_cache.json" - cache_file.write_text( +async def test_falls_back_to_json_when_toml_missing(tmp_path: Path) -> None: + (tmp_path / "update_cache.json").write_text( json.dumps({"latest_version": "1.0.0", "stored_at_timestamp": 1_600_000_000}) ) repository = FileSystemUpdateCacheRepository(base_path=tmp_path) - await repository.set( - UpdateCache(latest_version="1.1.0", stored_at_timestamp=1_700_200_000) - ) - - content = json.loads(cache_file.read_text()) - assert content["latest_version"] == "1.1.0" - assert content["stored_at_timestamp"] == 1_700_200_000 - assert content.get("seen_whats_new_version") is None - - -@pytest.mark.asyncio -async def test_reads_cache_with_seen_whats_new_version(tmp_path: Path) -> None: - cache_file = tmp_path / "update_cache.json" - cache_file.write_text( - json.dumps({ - "latest_version": "1.2.3", - "stored_at_timestamp": 1_700_000_000, - "seen_whats_new_version": "1.2.0", - }) - ) - repository = FileSystemUpdateCacheRepository(base_path=tmp_path) - cache = await repository.get() assert cache is not None - assert cache.latest_version == "1.2.3" - assert cache.stored_at_timestamp == 1_700_000_000 + assert cache.latest_version == "1.0.0" + assert cache.stored_at_timestamp == 1_600_000_000 + + +@pytest.mark.asyncio +async def test_returns_none_when_no_cache_exists(tmp_path: Path) -> None: + repository = FileSystemUpdateCacheRepository(base_path=tmp_path) + + cache = await repository.get() + + assert cache is None + + +@pytest.mark.asyncio +async def test_returns_none_when_toml_is_corrupted(tmp_path: Path) -> None: + (tmp_path / "cache.toml").write_text("{not-toml") + repository = FileSystemUpdateCacheRepository(base_path=tmp_path) + + cache = await repository.get() + + assert cache is None + + +@pytest.mark.asyncio +async def test_set_writes_to_toml(tmp_path: Path) -> None: + repository = FileSystemUpdateCacheRepository(base_path=tmp_path) + + await repository.set( + UpdateCache(latest_version="1.1.0", stored_at_timestamp=1_700_200_000) + ) + + with (tmp_path / "cache.toml").open("rb") as f: + data = tomllib.load(f) + assert data["update_cache"]["latest_version"] == "1.1.0" + assert data["update_cache"]["stored_at_timestamp"] == 1_700_200_000 + assert data["update_cache"].get("seen_whats_new_version") is None + + +@pytest.mark.asyncio +async def test_reads_cache_with_seen_whats_new_version(tmp_path: Path) -> None: + _write_cache_toml( + tmp_path, + { + "update_cache": { + "latest_version": "1.2.3", + "stored_at_timestamp": 1_700_000_000, + "seen_whats_new_version": "1.2.0", + } + }, + ) + repository = FileSystemUpdateCacheRepository(base_path=tmp_path) + + cache = await repository.get() + + assert cache is not None assert cache.seen_whats_new_version == "1.2.0" @@ -98,22 +119,43 @@ async def test_writes_cache_with_seen_whats_new_version(tmp_path: Path) -> None: ) ) - cache_file = tmp_path / "update_cache.json" - content = json.loads(cache_file.read_text()) - assert content["latest_version"] == "1.1.0" - assert content["stored_at_timestamp"] == 1_700_200_000 - assert content["seen_whats_new_version"] == "1.1.0" + with (tmp_path / "cache.toml").open("rb") as f: + data = tomllib.load(f) + assert data["update_cache"]["seen_whats_new_version"] == "1.1.0" @pytest.mark.asyncio -async def test_silently_ignores_errors_when_writing_cache_fails(tmp_path: Path) -> None: - cache_dir = tmp_path / ".vibe" - cache_dir.mkdir() - (cache_dir / "update_cache.json").mkdir() +async def test_set_preserves_other_sections(tmp_path: Path) -> None: + _write_cache_toml(tmp_path, {"feedback": {"last_shown_at": 123.0}}) repository = FileSystemUpdateCacheRepository(base_path=tmp_path) await repository.set( - UpdateCache(latest_version="1.2.0", stored_at_timestamp=1_700_300_000) + UpdateCache(latest_version="2.0.0", stored_at_timestamp=1_800_000_000) ) - assert (cache_dir / "update_cache.json").is_dir() + with (tmp_path / "cache.toml").open("rb") as f: + data = tomllib.load(f) + assert data["feedback"]["last_shown_at"] == 123.0 + assert data["update_cache"]["latest_version"] == "2.0.0" + + +@pytest.mark.asyncio +async def test_prefers_toml_over_json(tmp_path: Path) -> None: + _write_cache_toml( + tmp_path, + { + "update_cache": { + "latest_version": "2.0.0", + "stored_at_timestamp": 1_800_000_000, + } + }, + ) + (tmp_path / "update_cache.json").write_text( + json.dumps({"latest_version": "1.0.0", "stored_at_timestamp": 1_600_000_000}) + ) + repository = FileSystemUpdateCacheRepository(base_path=tmp_path) + + cache = await repository.get() + + assert cache is not None + assert cache.latest_version == "2.0.0" diff --git a/uv.lock b/uv.lock index d4b0b35..6c78cfa 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.12" [options] -exclude-newer = "2026-04-14T13:58:26.621313Z" +exclude-newer = "2026-04-21T14:17:22.54525Z" exclude-newer-span = "P7D" [options.exclude-newer-package] @@ -808,7 +808,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.8.1" +version = "2.9.0" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, diff --git a/vibe/__init__.py b/vibe/__init__.py index d8b398e..3caa50d 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.8.1" +__version__ = "2.9.0" diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py index af383f1..6e92d6d 100644 --- a/vibe/acp/acp_agent_loop.py +++ b/vibe/acp/acp_agent_loop.py @@ -1,8 +1,10 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable from contextlib import aclosing +import inspect +import logging import os from pathlib import Path import sys @@ -45,8 +47,10 @@ from acp.schema import ( PromptCapabilities, ResumeSessionResponse, SessionCapabilities, + SessionCloseCapabilities, SessionConfigOptionBoolean, SessionConfigOptionSelect, + SessionForkCapabilities, SessionInfo, SessionListCapabilities, SetSessionConfigOptionResponse, @@ -60,13 +64,14 @@ from acp.schema import ( Usage, UsageUpdate, ) -from pydantic import BaseModel, ConfigDict, SkipValidation +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError from vibe import VIBE_ROOT, __version__ from vibe.acp.acp_logger import acp_message_observer from vibe.acp.commands import AcpCommandRegistry from vibe.acp.exceptions import ( ConfigurationError, + ContextTooLongError, ConversationLimitError, InternalError, InvalidRequestError, @@ -76,13 +81,18 @@ from vibe.acp.exceptions import ( SessionNotFoundError, UnauthenticatedError, ) +from vibe.acp.session import AcpSessionLoop from vibe.acp.tools.base import BaseAcpTool from vibe.acp.tools.session_update import ( tool_call_session_update, tool_result_session_update, ) from vibe.acp.utils import ( + THINKING_LEVELS, + ThinkingLevel, ToolOption, + build_mode_state, + build_model_state, build_permission_options, create_assistant_message_replay, create_compact_end_session_update, @@ -93,8 +103,7 @@ from vibe.acp.utils import ( create_user_message_replay, get_proxy_help_text, is_valid_acp_mode, - make_mode_response, - make_model_response, + make_thinking_response, ) from vibe.core.agent_loop import AgentLoop from vibe.core.agents.models import CHAT as CHAT_AGENT, BuiltinAgentName @@ -106,6 +115,7 @@ from vibe.core.config import ( load_dotenv_values, ) from vibe.core.data_retention import DATA_RETENTION_MESSAGE +from vibe.core.hooks.config import load_hooks_from_fs from vibe.core.proxy_setup import ( ProxySetupError, parse_proxy_command, @@ -114,6 +124,9 @@ from vibe.core.proxy_setup import ( ) from vibe.core.session.session_loader import SessionLoader 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.permissions import RequiredPermission from vibe.core.types import ( AgentProfileChangedEvent, @@ -122,7 +135,7 @@ from vibe.core.types import ( AssistantEvent, CompactEndEvent, CompactStartEvent, - EntrypointMetadata, + ContextTooLongError as CoreContextTooLongError, LLMMessage, RateLimitError as CoreRateLimitError, ReasoningEvent, @@ -137,6 +150,25 @@ from vibe.core.utils import ( get_user_cancellation_message, ) +logger = logging.getLogger("vibe") + + +class ForkSessionParams(BaseModel): + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + message_id: str | None = Field(default=None, alias="messageId") + + +class TelemetrySendNotification(BaseModel): + model_config = ConfigDict(extra="ignore") + + event: str + properties: dict[str, Any] = Field(default_factory=dict) + session_id: str = Field(validation_alias=AliasChoices("session_id", "sessionId")) + + +_EVENT_DISPATCHERS: dict[str, Callable[[TelemetryClient, dict[str, Any]], None]] = {} + def _resolved_user_message_id(client_message_id: str | None) -> str: if client_message_id is not None: @@ -144,14 +176,6 @@ def _resolved_user_message_id(client_message_id: str | None) -> str: return str(uuid4()) -class AcpSessionLoop(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True) - id: str - agent_loop: AgentLoop - command_registry: SkipValidation[AcpCommandRegistry] - task: asyncio.Task[None] | None = None - - class VibeAcpAgentLoop(AcpAgent): client: Client @@ -220,7 +244,9 @@ class VibeAcpAgentLoop(AcpAgent): audio=False, embedded_context=True, image=False ), session_capabilities=SessionCapabilities( - list=SessionListCapabilities() + close=SessionCloseCapabilities(), + list=SessionListCapabilities(), + fork=SessionForkCapabilities(), ), ), protocol_version=PROTOCOL_VERSION, @@ -240,7 +266,7 @@ class VibeAcpAgentLoop(AcpAgent): raise NotImplementedMethodError("authenticate") def _build_entrypoint_metadata(self) -> EntrypointMetadata: - return EntrypointMetadata( + return build_entrypoint_metadata( agent_entrypoint="acp", agent_version=__version__, client_name=self.client_info.name if self.client_info else "", @@ -266,17 +292,44 @@ class VibeAcpAgentLoop(AcpAgent): ) self.sessions[session.id] = session - command_registry.set_on_changed( - lambda: self._send_available_commands(session.id) - ) + async def _on_commands_changed() -> None: + session.spawn(self._send_available_commands(session)) - if not agent_loop.auto_approve: + command_registry.set_on_changed(_on_commands_changed) + + if not agent_loop.bypass_tool_permissions: agent_loop.set_approval_callback(self._create_approval_callback(session.id)) - asyncio.create_task(self._send_available_commands(session.id)) + session.spawn(self._send_available_commands(session)) return session + def _create_agent_loop( + self, config: VibeConfig, agent_name: str, hook_config_result: Any = None + ) -> AgentLoop: + agent_loop = AgentLoop( + config=config, + agent_name=agent_name, + enable_streaming=True, + entrypoint_metadata=self._build_entrypoint_metadata(), + defer_heavy_init=True, + hook_config_result=hook_config_result, + ) + agent_loop.agent_manager.register_agent(CHAT_AGENT) + return agent_loop + + def _build_session_state( + self, session: AcpSessionLoop + ) -> tuple[Any, Any, Any, Any]: + modes_state, modes_config = build_mode_state( + list(session.agent_loop.agent_manager.available_agents.values()), + session.agent_loop.agent_profile.name, + ) + models_state, models_config = build_model_state( + session.agent_loop.config.models, session.agent_loop.config.active_model + ) + return modes_state, modes_config, models_state, models_config + @override async def new_session( self, @@ -288,16 +341,12 @@ class VibeAcpAgentLoop(AcpAgent): os.chdir(cwd) config = self._load_config() + hook_config_result = load_hooks_from_fs(config) try: - agent_loop = AgentLoop( - config=config, - agent_name=BuiltinAgentName.DEFAULT, - enable_streaming=True, - entrypoint_metadata=self._build_entrypoint_metadata(), - defer_heavy_init=True, + agent_loop = self._create_agent_loop( + config, BuiltinAgentName.DEFAULT, hook_config_result=hook_config_result ) - agent_loop.agent_manager.register_agent(CHAT_AGENT) # NOTE: For now, we pin session.id to agent_loop.session_id right after init time. # We should just use agent_loop.session_id everywhere, but it can still change during # session lifetime (e.g. agent_loop.compact is called). @@ -308,19 +357,13 @@ class VibeAcpAgentLoop(AcpAgent): agent_loop.emit_new_session_telemetry() - modes_state, modes_config = make_mode_response( - list(agent_loop.agent_manager.available_agents.values()), - session.agent_loop.agent_profile.name, - ) - models_state, models_config = make_model_response( - agent_loop.config.models, agent_loop.config.active_model - ) + modes_state, _, models_state, _ = self._build_session_state(session) return NewSessionResponse( session_id=session.id, models=models_state, modes=modes_state, - config_options=[modes_config, models_config], + config_options=self._build_config_options(session), ) def _get_acp_tool_overrides(self) -> list[Path]: @@ -439,7 +482,7 @@ class VibeAcpAgentLoop(AcpAgent): except Exception: pass - asyncio.create_task(_send()) + session.spawn(_send()) async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None: if not msg.tool_calls: @@ -476,9 +519,7 @@ class VibeAcpAgentLoop(AcpAgent): session_id=session_id, update=result_update ) - async def _send_available_commands(self, session_id: str) -> None: - session = self._get_session(session_id) - + async def _send_available_commands(self, session: AcpSessionLoop) -> None: commands: list[AvailableCommand] = [] for cmd in session.command_registry.commands.values(): @@ -510,7 +551,7 @@ class VibeAcpAgentLoop(AcpAgent): ) await self.client.session_update( - session_id=session_id, update=update_available_commands(commands) + session_id=session.id, update=update_available_commands(commands) ) @override @@ -525,6 +566,7 @@ class VibeAcpAgentLoop(AcpAgent): os.chdir(cwd) config = self._load_config() + hook_config_result = load_hooks_from_fs(config) session_dir = SessionLoader.find_session_by_id( session_id, config.session_logging @@ -533,42 +575,35 @@ class VibeAcpAgentLoop(AcpAgent): raise SessionNotFoundError(session_id) try: - loaded_messages, _ = SessionLoader.load_session(session_dir) + loaded_messages, metadata = SessionLoader.load_session(session_dir) except Exception as e: raise SessionLoadError(session_id, str(e)) from e - agent_loop = AgentLoop( - config=config, - agent_name=BuiltinAgentName.DEFAULT, - enable_streaming=True, - entrypoint_metadata=self._build_entrypoint_metadata(), - defer_heavy_init=True, + agent_loop = self._create_agent_loop( + config, BuiltinAgentName.DEFAULT, hook_config_result=hook_config_result + ) + loaded_session_id = metadata.get("session_id", agent_loop.session_id) + agent_loop.session_id = loaded_session_id + agent_loop.parent_session_id = metadata.get("parent_session_id") + agent_loop.session_logger.resume_existing_session( + loaded_session_id, session_dir ) - agent_loop.agent_manager.register_agent(CHAT_AGENT) non_system_messages = [ msg for msg in loaded_messages if msg.role != Role.system ] - - agent_loop.messages.extend(non_system_messages) - + if non_system_messages: + agent_loop.messages.extend(non_system_messages) session = await self._create_acp_session(session_id, agent_loop) - - await self._replay_conversation_history(session_id, non_system_messages) + await self._replay_conversation_history(session.id, non_system_messages) self._send_usage_update(session) - modes_state, modes_config = make_mode_response( - list(agent_loop.agent_manager.available_agents.values()), - session.agent_loop.agent_profile.name, - ) - models_state, models_config = make_model_response( - agent_loop.config.models, agent_loop.config.active_model - ) + modes_state, _, models_state, _ = self._build_session_state(session) return LoadSessionResponse( models=models_state, modes=modes_state, - config_options=[modes_config, models_config], + config_options=self._build_config_options(session), ) async def _apply_mode_change(self, session: AcpSessionLoop, mode_id: str) -> bool: @@ -578,7 +613,7 @@ class VibeAcpAgentLoop(AcpAgent): await session.agent_loop.switch_agent(mode_id) - if session.agent_loop.auto_approve: + if session.agent_loop.bypass_tool_permissions: session.agent_loop.approval_callback = None else: session.agent_loop.set_approval_callback( @@ -587,20 +622,27 @@ class VibeAcpAgentLoop(AcpAgent): return True + async def _reload_config(self, session: AcpSessionLoop) -> None: + new_config = VibeConfig.load( + tool_paths=session.agent_loop.config.tool_paths, + disabled_tools=["ask_user_question"], + ) + await session.agent_loop.reload_with_initial_messages(base_config=new_config) + async def _apply_model_change(self, session: AcpSessionLoop, model_id: str) -> bool: model_aliases = [model.alias for model in session.agent_loop.config.models] if model_id not in model_aliases: return False VibeConfig.save_updates({"active_model": model_id}) + await self._reload_config(session) + return True - new_config = VibeConfig.load( - tool_paths=session.agent_loop.config.tool_paths, - disabled_tools=["ask_user_question"], - ) - - await session.agent_loop.reload_with_initial_messages(base_config=new_config) - + async def _apply_thinking_change( + self, session: AcpSessionLoop, level: ThinkingLevel + ) -> bool: + session.agent_loop.config.set_thinking(level) + await self._reload_config(session) return True @override @@ -636,6 +678,10 @@ class VibeAcpAgentLoop(AcpAgent): success = await self._apply_mode_change(session, value) case "model" if isinstance(value, str): success = await self._apply_model_change(session, value) + case "thinking" if isinstance(value, str) and value in THINKING_LEVELS: + success = await self._apply_thinking_change( + session, cast(ThinkingLevel, value) + ) case _: success = False @@ -682,7 +728,7 @@ class VibeAcpAgentLoop(AcpAgent): ) -> PromptResponse: session = self._get_session(session_id) - if session.task is not None: + if session.prompt_task is not None: raise InvalidRequestError( "Concurrent prompts are not supported yet, wait for agent loop to finish" ) @@ -713,8 +759,8 @@ class VibeAcpAgentLoop(AcpAgent): await self.client.session_update(session_id=session.id, update=update) try: - session.task = asyncio.create_task(agent_loop_task()) - await session.task + task = session.set_prompt_task(agent_loop_task()) + await task except asyncio.CancelledError: self._send_usage_update(session) @@ -727,15 +773,15 @@ class VibeAcpAgentLoop(AcpAgent): except CoreRateLimitError as e: raise RateLimitError.from_core(e) from e + except CoreContextTooLongError as e: + raise ContextTooLongError.from_core(e) from e + except ConversationLimitException as e: raise ConversationLimitError(str(e)) from e except Exception as e: raise InternalError(str(e)) from e - finally: - session.task = None - self._send_usage_update(session) return PromptResponse( stop_reason="end_turn", @@ -803,12 +849,13 @@ class VibeAcpAgentLoop(AcpAgent): if command is None: return None + session.agent_loop.telemetry_client.send_slash_command_used(cmd_name, "builtin") handler = getattr(self, command.handler) return await handler(session, text_prompt, message_id) async def _run_agent_loop( self, session: AcpSessionLoop, prompt: str, client_message_id: str | None = None - ) -> AsyncGenerator[SessionUpdate]: + ) -> AsyncGenerator[SessionUpdate | UsageUpdate]: rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd()) async with aclosing( @@ -846,6 +893,7 @@ class VibeAcpAgentLoop(AcpAgent): session_update = tool_result_session_update(event) if session_update: yield session_update + self._send_usage_update(session) elif isinstance(event, ToolStreamEvent): yield ToolCallProgress( @@ -874,14 +922,31 @@ class VibeAcpAgentLoop(AcpAgent): async def close_session( self, session_id: str, **kwargs: Any ) -> CloseSessionResponse | None: - raise NotImplementedMethodError("close_session") + session = self._get_session(session_id) + self.sessions.pop(session_id, None) + + await session.close() + await self._close_agent_loop(session.agent_loop) + + return CloseSessionResponse() + + async def _close_agent_loop(self, agent_loop: AgentLoop) -> None: + deferred_init_thread = agent_loop._deferred_init_thread + if deferred_init_thread is not None and deferred_init_thread.is_alive(): + await asyncio.to_thread(deferred_init_thread.join) + + backend_close = getattr(agent_loop.backend, "close", None) + if callable(backend_close): + close_result = backend_close() + if inspect.isawaitable(close_result): + await close_result + + await agent_loop.telemetry_client.aclose() @override async def cancel(self, session_id: str, **kwargs: Any) -> None: session = self._get_session(session_id) - if session.task and not session.task.done(): - session.task.cancel() - session.task = None + await session.cancel_prompt() @override async def fork_session( @@ -891,7 +956,41 @@ class VibeAcpAgentLoop(AcpAgent): mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None, **kwargs: Any, ) -> ForkSessionResponse: - raise NotImplementedMethodError("fork_session") + load_dotenv_values() + os.chdir(cwd) + + source_session = self._get_session(session_id) + try: + message_id = ForkSessionParams.model_validate(kwargs).message_id + except ValidationError as e: + raise InvalidRequestError(f"Invalid fork parameters: {e}") from e + if ( + source_session.prompt_task is not None + and not source_session.prompt_task.done() + ): + raise InvalidRequestError( + "Cannot fork a session while the agent loop is running" + ) + + try: + agent_loop = await source_session.agent_loop.fork(message_id) + agent_loop.agent_manager.register_agent(CHAT_AGENT) + session = await self._create_acp_session(agent_loop.session_id, agent_loop) + except InvalidRequestError: + raise + except ValueError as e: + raise InvalidRequestError(str(e)) from e + except Exception as e: + raise ConfigurationError(str(e)) from e + + modes_state, _, models_state, _ = self._build_session_state(session) + + return ForkSessionResponse( + session_id=session.id, + models=models_state, + modes=modes_state, + config_options=self._build_config_options(session), + ) @override async def resume_session( @@ -909,7 +1008,34 @@ class VibeAcpAgentLoop(AcpAgent): @override async def ext_notification(self, method: str, params: dict) -> None: - raise NotImplementedMethodError("ext_notification") + # ACP strips the leading "_" before delegating extension notifications here. + if method == "telemetry/send": + self._handle_telemetry_notification(params) + + def _handle_telemetry_notification(self, params: dict[str, Any]) -> None: + try: + notification = TelemetrySendNotification.model_validate(params) + except ValidationError as exc: + raise InvalidRequestError( + f"Invalid ACP telemetry notification: {exc}" + ) from exc + + session = self.sessions.get(notification.session_id) + if session is None: + logger.warning( + "Ignoring ACP telemetry notification because session could not be resolved: %s", + notification.session_id, + ) + return + + dispatcher = _EVENT_DISPATCHERS.get(notification.event) + if dispatcher is None: + logger.warning( + "Ignoring unsupported ACP telemetry event: %s", notification.event + ) + return + + dispatcher(session.agent_loop.telemetry_client, notification.properties) @override def on_connect(self, conn: Client) -> None: @@ -962,6 +1088,8 @@ class VibeAcpAgentLoop(AcpAgent): tool_call_id = str(uuid4()) old_tokens = session.agent_loop.stats.context_tokens + parts = text_prompt.strip().split(None, 1) + cmd_args = parts[1] if len(parts) > 1 else "" start_event = CompactStartEvent( current_context_tokens=old_tokens or 0, @@ -973,7 +1101,7 @@ class VibeAcpAgentLoop(AcpAgent): update=create_compact_start_session_update(start_event), ) - await session.agent_loop.compact() + await session.agent_loop.compact(extra_instructions=cmd_args.strip()) new_tokens = session.agent_loop.stats.context_tokens end_event = CompactEndEvent( @@ -1070,13 +1198,16 @@ class VibeAcpAgentLoop(AcpAgent): ) -> list[SessionConfigOptionSelect | SessionConfigOptionBoolean]: """Build the current modes + models config options for a session.""" profiles = list(session.agent_loop.agent_manager.available_agents.values()) - _, modes_config = make_mode_response( + _, modes_config = build_mode_state( profiles, session.agent_loop.agent_profile.name ) - _, models_config = make_model_response( + _, models_config = build_model_state( session.agent_loop.config.models, session.agent_loop.config.active_model ) - return [modes_config, models_config] + thinking_config = make_thinking_response( + session.agent_loop.config.get_active_model().thinking + ) + return [modes_config, models_config, thinking_config] async def _send_config_option_update(self, session: AcpSessionLoop) -> None: """Push updated config options (modes, models) to the client.""" diff --git a/vibe/acp/commands/registry.py b/vibe/acp/commands/registry.py index b48df8b..28396b0 100644 --- a/vibe/acp/commands/registry.py +++ b/vibe/acp/commands/registry.py @@ -51,8 +51,9 @@ def _build_commands() -> dict[str, AcpCommand]: ), "compact": AcpCommand( name="compact", - description="Compact conversation history by summarizing", + description="Compact conversation history by summarizing. Optionally pass instructions to guide the summary", handler="_handle_compact", + input_hint="Optional instructions to guide the compaction summary", ), "reload": AcpCommand( name="reload", diff --git a/vibe/acp/entrypoint.py b/vibe/acp/entrypoint.py index d998794..772b8bd 100644 --- a/vibe/acp/entrypoint.py +++ b/vibe/acp/entrypoint.py @@ -15,6 +15,7 @@ from vibe.core.config.harness_files import ( ) from vibe.core.logger import logger from vibe.core.paths import HISTORY_FILE +from vibe.core.telemetry.build_metadata import build_entrypoint_metadata # Configure line buffering for subprocess communication sys.stdout.reconfigure(line_buffering=True) # pyright: ignore[reportAttributeAccessIssue] @@ -86,7 +87,14 @@ def main() -> None: bootstrap_config_files() args = parse_arguments() if args.setup: - run_onboarding() + run_onboarding( + entrypoint_metadata=build_entrypoint_metadata( + agent_entrypoint="acp", + agent_version=__version__, + client_name="vibe_acp", + client_version=__version__, + ) + ) sys.exit(0) try: diff --git a/vibe/acp/exceptions.py b/vibe/acp/exceptions.py index c188cdc..512559c 100644 --- a/vibe/acp/exceptions.py +++ b/vibe/acp/exceptions.py @@ -19,7 +19,10 @@ from typing import Any from acp import RequestError from vibe.core.config import MissingAPIKeyError -from vibe.core.types import RateLimitError as CoreRateLimitError +from vibe.core.types import ( + ContextTooLongError as CoreContextTooLongError, + RateLimitError as CoreRateLimitError, +) # JSON-RPC 2.0 standard codes UNAUTHENTICATED = -32000 @@ -31,6 +34,7 @@ INTERNAL_ERROR = -32603 RATE_LIMITED = -31001 CONFIGURATION_ERROR = -31002 CONVERSATION_LIMIT = -31003 +CONTEXT_TOO_LONG = -31004 class VibeRequestError(RequestError): @@ -100,6 +104,21 @@ class RateLimitError(VibeRequestError): return cls(exc.provider, exc.model) +class ContextTooLongError(VibeRequestError): + code = CONTEXT_TOO_LONG + + def __init__(self, provider: str, model: str) -> None: + super().__init__( + message=f"Context too long for {provider} (model: {model}). " + "Use /rewind to undo recent actions, then /compact to summarize.", + data={"provider": provider, "model": model}, + ) + + @classmethod + def from_core(cls, exc: CoreContextTooLongError) -> ContextTooLongError: + return cls(exc.provider, exc.model) + + class ConfigurationError(VibeRequestError): code = CONFIGURATION_ERROR diff --git a/vibe/acp/session.py b/vibe/acp/session.py new file mode 100644 index 0000000..4d6247a --- /dev/null +++ b/vibe/acp/session.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Coroutine +from typing import Any + +from vibe.acp.commands import AcpCommandRegistry +from vibe.core.agent_loop import AgentLoop + + +class AcpSessionLoop: + """Holds the state for a single ACP session. + + All session-scoped async work (background updates, the prompt task) + is tracked internally. ``close`` cancels everything; + ``cancel_prompt`` cancels only the active prompt. + """ + + def __init__( + self, *, id: str, agent_loop: AgentLoop, command_registry: AcpCommandRegistry + ) -> None: + self.id = id + self.agent_loop = agent_loop + self.command_registry = command_registry + self._closed = False + self._tasks: set[asyncio.Task[None]] = set() + self._prompt_task: asyncio.Task[None] | None = None + + # -- public API ------------------------------------------------------------ + + @property + def prompt_task(self) -> asyncio.Task[None] | None: + return self._prompt_task + + def spawn(self, coro: Coroutine[Any, Any, None]) -> asyncio.Task[None] | None: + """Launch a background coroutine tied to this session.""" + if self._closed: + coro.close() + return None + task = asyncio.create_task(coro) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + return task + + def set_prompt_task(self, coro: Coroutine[Any, Any, None]) -> asyncio.Task[None]: + """Create the prompt task. Only one may be active at a time.""" + task = asyncio.create_task(coro) + self._prompt_task = task + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + task.add_done_callback(lambda _: self._clear_prompt_task(task)) + return task + + async def cancel_prompt(self) -> None: + """Cancel the active prompt task, if any.""" + task = self._prompt_task + if task is None or task.done(): + return + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + async def close(self) -> None: + """Cancel all tasks (prompt + background) and mark session closed.""" + self._closed = True + for t in self._tasks: + t.cancel() + await asyncio.gather(*self._tasks, return_exceptions=True) + self._tasks.clear() + self._prompt_task = None + + # -- private --------------------------------------------------------------- + + def _clear_prompt_task(self, task: asyncio.Task[None]) -> None: + if self._prompt_task is task: + self._prompt_task = None diff --git a/vibe/acp/utils.py b/vibe/acp/utils.py index 717c219..9482f77 100644 --- a/vibe/acp/utils.py +++ b/vibe/acp/utils.py @@ -21,6 +21,7 @@ from acp.schema import ( ) from vibe.core.agents.models import AgentProfile, AgentType +from vibe.core.config._settings import THINKING_LEVELS, ThinkingLevel 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 @@ -100,7 +101,7 @@ def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool: ) -def make_mode_response( +def build_mode_state( profiles: list[AgentProfile], current_mode_id: str ) -> tuple[SessionModeState, SessionConfigOptionSelect]: session_modes: list[SessionMode] = [] @@ -138,7 +139,7 @@ def make_mode_response( return state, config -def make_model_response( +def build_model_state( models: list[ModelConfig], current_model_id: str ) -> tuple[SessionModelState, SessionConfigOptionSelect]: model_infos: list[ModelInfo] = [] @@ -166,6 +167,22 @@ def make_model_response( return state, config_option +def make_thinking_response( + current_thinking: ThinkingLevel, +) -> SessionConfigOptionSelect: + return SessionConfigOptionSelect( + id="thinking", + name="Thinking", + current_value=current_thinking, + category="thinking", + type="select", + options=[ + SessionConfigSelectOption(value=level, name=level.capitalize()) + for level in THINKING_LEVELS + ], + ) + + def create_compact_start_session_update(event: CompactStartEvent) -> ToolCallStart: # WORKAROUND: Using tool_call to communicate compact events to the client. # This should be revisited when the ACP protocol defines how compact events diff --git a/vibe/cli/autocompletion/slash_command.py b/vibe/cli/autocompletion/slash_command.py index 81ec969..612e5a0 100644 --- a/vibe/cli/autocompletion/slash_command.py +++ b/vibe/cli/autocompletion/slash_command.py @@ -5,8 +5,6 @@ from textual import events from vibe.cli.autocompletion.base import CompletionResult, CompletionView from vibe.core.autocompletion.completers import CommandCompleter -MAX_SUGGESTIONS_COUNT = 10 - class SlashCommandController: def __init__(self, completer: CommandCompleter, view: CompletionView) -> None: @@ -34,9 +32,7 @@ class SlashCommandController: return suggestions = self._completer.get_completion_items(text, cursor_index) - if len(suggestions) > MAX_SUGGESTIONS_COUNT: - suggestions = suggestions[:MAX_SUGGESTIONS_COUNT] - if suggestions: + if suggestions and all(alias != text for alias, _ in suggestions): self._suggestions = suggestions self._selected_index = 0 self._view.render_completion_suggestions( diff --git a/vibe/cli/cache.py b/vibe/cli/cache.py new file mode 100644 index 0000000..66a5cb4 --- /dev/null +++ b/vibe/cli/cache.py @@ -0,0 +1,32 @@ +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 d601a47..0241d59 100644 --- a/vibe/cli/cli.py +++ b/vibe/cli/cli.py @@ -5,6 +5,7 @@ from pathlib import Path import sys from rich import print as rprint +from rich.console import Console import tomli_w from vibe import __version__ @@ -18,16 +19,29 @@ from vibe.core.config import ( load_dotenv_values, ) from vibe.core.config.harness_files import get_harness_files_manager +from vibe.core.hooks.config import load_hooks_from_fs from vibe.core.logger import logger from vibe.core.paths import HISTORY_FILE from vibe.core.programmatic import run_programmatic from vibe.core.session.session_loader import SessionLoader +from vibe.core.telemetry.build_metadata import build_entrypoint_metadata +from vibe.core.telemetry.types import EntrypointMetadata from vibe.core.tracing import setup_tracing -from vibe.core.types import EntrypointMetadata, LLMMessage, OutputFormat, Role +from vibe.core.trusted_folders import find_trustable_files, trusted_folders_manager +from vibe.core.types import LLMMessage, OutputFormat, Role from vibe.core.utils import ConversationLimitException from vibe.setup.onboarding import run_onboarding +def _build_cli_entrypoint_metadata() -> EntrypointMetadata: + return build_entrypoint_metadata( + agent_entrypoint="cli", + agent_version=__version__, + client_name="vibe_cli", + client_version=__version__, + ) + + def get_initial_agent_name(args: argparse.Namespace) -> str: if args.prompt is not None and args.agent == BuiltinAgentName.DEFAULT: return BuiltinAgentName.AUTO_APPROVE @@ -49,11 +63,18 @@ def get_prompt_from_stdin() -> str | None: return None -def load_config_or_exit() -> VibeConfig: +def load_config_or_exit(*, interactive: bool) -> VibeConfig: try: return VibeConfig.load() - except MissingAPIKeyError: - run_onboarding() + except MissingAPIKeyError as e: + if not interactive: + print( + f"Error: {e}. Set the environment variable (e.g. in ~/.vibe/.env " + "or your shell), or run `vibe --setup` once interactively.", + file=sys.stderr, + ) + sys.exit(1) + run_onboarding(entrypoint_metadata=_build_cli_entrypoint_metadata()) return VibeConfig.load() except MissingPromptFileError as e: rprint(f"[yellow]Invalid system prompt id: {e}[/]") @@ -63,6 +84,26 @@ def load_config_or_exit() -> VibeConfig: sys.exit(1) +def warn_if_workdir_trust_is_unset() -> None: + try: + cwd = Path.cwd() + except FileNotFoundError: + return + if cwd.resolve() == Path.home().resolve(): + return + if trusted_folders_manager.is_trusted(cwd) is not None: + return + detected = find_trustable_files(cwd) + if not detected: + return + files_str = ", ".join(detected) + Console(stderr=True).print( + f"[yellow]Warning:[/] {cwd} is not trusted; " + f"project configuration ({files_str}) will be ignored. " + "Re-run with --trust to trust this folder temporarily." + ) + + def bootstrap_config_files() -> None: mgr = get_harness_files_manager() config_file = mgr.user_config_file @@ -98,11 +139,14 @@ def load_session( session_to_load = None if args.continue_session: - session_to_load = SessionLoader.find_latest_session(config.session_logging) + cwd = Path.cwd().resolve() + session_to_load = SessionLoader.find_latest_session( + config.session_logging, working_directory=cwd + ) if not session_to_load: rprint( f"[red]No previous sessions found in " - f"{config.session_logging.save_dir}[/]" + f"{config.session_logging.save_dir} for {cwd=}[/]" ) sys.exit(1) elif args.resume is True: @@ -135,6 +179,7 @@ def _resume_previous_session( _, metadata = SessionLoader.load_session(session_path) session_id = metadata.get("session_id", agent_loop.session_id) agent_loop.session_id = session_id + agent_loop.parent_session_id = metadata.get("parent_session_id") agent_loop.session_logger.resume_existing_session(session_id, session_path) logger.info( @@ -147,12 +192,14 @@ def run_cli(args: argparse.Namespace) -> None: bootstrap_config_files() if args.setup: - run_onboarding() + run_onboarding(entrypoint_metadata=_build_cli_entrypoint_metadata()) sys.exit(0) try: initial_agent_name = get_initial_agent_name(args) - config = load_config_or_exit() + is_interactive = args.prompt is None + config = load_config_or_exit(interactive=is_interactive) + hook_config_result = load_hooks_from_fs(config) setup_tracing(config) if args.enabled_tools: @@ -162,6 +209,7 @@ def run_cli(args: argparse.Namespace) -> None: stdin_prompt = get_prompt_from_stdin() if args.prompt is not None: + warn_if_workdir_trust_is_unset() config.disabled_tools = [*config.disabled_tools, "ask_user_question"] programmatic_prompt = args.prompt or stdin_prompt if not programmatic_prompt: @@ -182,7 +230,9 @@ def run_cli(args: argparse.Namespace) -> None: output_format=output_format, previous_messages=loaded_session[0] if loaded_session else None, agent_name=initial_agent_name, - teleport=args.teleport and config.nuage_enabled, + teleport=args.teleport and config.vibe_code_enabled, + headless=True, + hook_config_result=hook_config_result, ) if final_response: print(final_response) @@ -201,13 +251,9 @@ def run_cli(args: argparse.Namespace) -> None: config, agent_name=initial_agent_name, enable_streaming=True, - entrypoint_metadata=EntrypointMetadata( - agent_entrypoint="cli", - agent_version=__version__, - client_name="vibe_cli", - client_version=__version__, - ), + entrypoint_metadata=_build_cli_entrypoint_metadata(), defer_heavy_init=True, + hook_config_result=hook_config_result, ) if loaded_session: diff --git a/vibe/cli/clipboard.py b/vibe/cli/clipboard.py index 16f5d15..3051501 100644 --- a/vibe/cli/clipboard.py +++ b/vibe/cli/clipboard.py @@ -30,17 +30,24 @@ def _has_cmd(cmd: str) -> bool: def _copy_pbcopy(text: str) -> None: - subprocess.run(["pbcopy"], input=text.encode("utf-8"), check=True) + subprocess.run( + ["pbcopy"], input=text.encode("utf-8"), check=True, stderr=subprocess.DEVNULL + ) def _copy_xclip(text: str) -> None: subprocess.run( - ["xclip", "-selection", "clipboard"], input=text.encode("utf-8"), check=True + ["xclip", "-selection", "clipboard"], + input=text.encode("utf-8"), + check=True, + stderr=subprocess.DEVNULL, ) def _copy_wl_copy(text: str) -> None: - subprocess.run(["wl-copy"], input=text.encode("utf-8"), check=True) + subprocess.run( + ["wl-copy"], input=text.encode("utf-8"), check=True, stderr=subprocess.DEVNULL + ) _CMD_STRATEGIES: list[tuple[str, Callable[[str], None]]] = [ @@ -137,24 +144,36 @@ def _get_selected_texts(app: App) -> list[str]: return selected_texts -def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None: - selected_texts = _get_selected_texts(app) - if not selected_texts: +def copy_text_to_clipboard( + app: App, + text: str, + *, + show_toast: bool = True, + success_message: str = "Copied to clipboard", +) -> str | None: + if not text: return None - combined_text = "\n".join(selected_texts) try: - _copy_to_clipboard(combined_text) + _copy_to_clipboard(text) if show_toast: - app.notify( - "Selection copied to clipboard", - severity="information", - timeout=2, - markup=False, - ) - return combined_text + app.notify(success_message, severity="information", timeout=2, markup=False) + return text except Exception: app.notify( "Failed to copy - clipboard not available", severity="warning", timeout=3 ) return None + + +def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None: + selected_texts = _get_selected_texts(app) + if not selected_texts: + return None + + return copy_text_to_clipboard( + app, + "\n".join(selected_texts), + show_toast=show_toast, + success_message="Selection copied to clipboard", + ) diff --git a/vibe/cli/commands.py b/vibe/cli/commands.py index 919c37a..9478506 100644 --- a/vibe/cli/commands.py +++ b/vibe/cli/commands.py @@ -1,24 +1,56 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass import sys +from vibe.cli.plan_offer.decide_plan_offer import PlanInfo + ALT_KEY = "⌥" if sys.platform == "darwin" else "Alt" +@dataclass(frozen=True) +class CommandAvailabilityContext: + vibe_code_enabled: bool = False + is_active_model_mistral: bool = False + plan_info: PlanInfo | None = None + + def is_teleport_available(self) -> bool: + return ( + self.vibe_code_enabled + and self.is_active_model_mistral + and self.plan_info is not None + and self.plan_info.is_teleport_eligible() + ) + + +CommandAvailability = Callable[[CommandAvailabilityContext], bool] + + @dataclass class Command: aliases: frozenset[str] description: str handler: str exits: bool = False + is_available: CommandAvailability | None = None class CommandRegistry: - def __init__(self, excluded_commands: list[str] | None = None) -> None: + def __init__( + self, + excluded_commands: list[str] | None = None, + availability_context: CommandAvailabilityContext | None = None, + ) -> None: if excluded_commands is None: excluded_commands = [] - self.commands = { + self._disabled_commands = set(excluded_commands) + self._availability_context = CommandAvailabilityContext() + self._commands: dict[str, Command] = {} + self.refresh(availability_context) + + def _build_commands(self) -> dict[str, Command]: + return { "help": Command( aliases=frozenset(["/help"]), description="Show help message", @@ -34,6 +66,11 @@ class CommandRegistry: description="Select active model", handler="_show_model", ), + "thinking": Command( + aliases=frozenset(["/thinking"]), + description="Select thinking level", + handler="_show_thinking", + ), "reload": Command( aliases=frozenset(["/reload"]), description="Reload configuration, agent instructions, and skills from disk", @@ -44,6 +81,11 @@ class CommandRegistry: description="Clear conversation history", handler="_clear_history", ), + "copy": Command( + aliases=frozenset(["/copy"]), + description="Copy the last agent message to the clipboard", + handler="_copy_last_agent_message", + ), "log": Command( aliases=frozenset(["/log"]), description="Show path to current interaction log file", @@ -56,7 +98,7 @@ class CommandRegistry: ), "compact": Command( aliases=frozenset(["/compact"]), - description="Compact conversation history by summarizing", + description="Compact conversation history by summarizing. Optionally pass instructions to guide the summary", handler="_compact_history", ), "exit": Command( @@ -72,8 +114,9 @@ class CommandRegistry: ), "teleport": Command( aliases=frozenset(["/teleport"]), - description="Teleport session to Vibe Nuage", + description="Teleport session to Vibe Code", handler="_teleport_command", + is_available=CommandAvailabilityContext.is_teleport_available, ), "proxy-setup": Command( aliases=frozenset(["/proxy-setup"]), @@ -120,16 +163,43 @@ class CommandRegistry: ), } - for command in excluded_commands: - self.commands.pop(command, None) + @property + def commands(self) -> dict[str, Command]: + return self._commands - self._alias_map = {} - for cmd_name, cmd in self.commands.items(): - for alias in cmd.aliases: - self._alias_map[alias] = cmd_name + def refresh( + self, availability_context: CommandAvailabilityContext | None = None + ) -> None: + self._availability_context = ( + availability_context or CommandAvailabilityContext() + ) + self._commands = { + name: command + for name, command in self._build_commands().items() + if name not in self._disabled_commands + and self._is_command_available(command) + } + + def _is_command_available(self, command: Command) -> bool: + if command.is_available is None: + return True + return command.is_available(self._availability_context) + + def _alias_map(self) -> dict[str, str]: + return { + alias: cmd_name + for cmd_name, cmd in self.commands.items() + for alias in cmd.aliases + } + + def get(self, name: str) -> Command | None: + return self.commands.get(name) + + def has_command(self, name: str) -> bool: + return name in self.commands def get_command_name(self, user_input: str) -> str | None: - return self._alias_map.get(user_input.lower().strip()) + return self._alias_map().get(user_input.lower().strip()) def parse_command(self, user_input: str) -> tuple[str, Command, str] | None: parts = user_input.strip().split(None, 1) diff --git a/vibe/cli/entrypoint.py b/vibe/cli/entrypoint.py index 8870329..6a16085 100644 --- a/vibe/cli/entrypoint.py +++ b/vibe/cli/entrypoint.py @@ -83,6 +83,13 @@ def parse_arguments() -> argparse.Namespace: metavar="DIR", help="Change to this directory before running", ) + parser.add_argument( + "--trust", + action="store_true", + help="Trust the working directory for this invocation only (not " + "persisted to trusted_folders.toml). Skips the trust prompt. " + "Use this for non-interactive automation.", + ) # Feature flag for teleport, not exposed to the user yet parser.add_argument("--teleport", action="store_true", help=argparse.SUPPRESS) @@ -106,18 +113,7 @@ def parse_arguments() -> argparse.Namespace: return parser.parse_args() -def check_and_resolve_trusted_folder() -> None: - try: - cwd = Path.cwd() - except FileNotFoundError: - rprint( - "[red]Error: Current working directory no longer exists.[/]\n" - "[yellow]The directory you started vibe from has been deleted. " - "Please change to an existing directory and try again, " - "or use --workdir to specify a working directory.[/]" - ) - sys.exit(1) - +def check_and_resolve_trusted_folder(cwd: Path) -> None: if cwd.resolve() == Path.home().resolve(): return @@ -157,9 +153,23 @@ def main() -> None: sys.exit(1) os.chdir(workdir) + try: + cwd = Path.cwd() + except FileNotFoundError: + rprint( + "[red]Error: Current working directory no longer exists.[/]\n" + "[yellow]The directory you started vibe from has been deleted. " + "Please change to an existing directory and try again, " + "or use --workdir to specify a working directory.[/]" + ) + sys.exit(1) + + if args.trust: + trusted_folders_manager.trust_for_session(cwd) + is_interactive = args.prompt is None if is_interactive: - check_and_resolve_trusted_folder() + check_and_resolve_trusted_folder(cwd) init_harness_files_manager("user", "project") from vibe.cli.cli import run_cli diff --git a/vibe/cli/narrator_manager/narrator_manager.py b/vibe/cli/narrator_manager/narrator_manager.py index 0d1c263..4efff37 100644 --- a/vibe/cli/narrator_manager/narrator_manager.py +++ b/vibe/cli/narrator_manager/narrator_manager.py @@ -41,7 +41,9 @@ class NarratorManager: self._audio_player = audio_player self._telemetry_client = telemetry_client config = config_getter() - self._turn_summary: TurnSummaryPort = self._make_turn_summary(config) + self._turn_summary: TurnSummaryPort = self._make_turn_summary( + config, telemetry_client + ) self._turn_summary.on_summary = self._on_turn_summary self._tts_client: TTSClientPort | None = self._make_tts_client(config) self._state = NarratorState.IDLE @@ -124,18 +126,28 @@ class NarratorManager: def sync(self) -> None: self.cancel() config = self._config_getter() - self.turn_summary = self._make_turn_summary(config) + self.turn_summary = self._make_turn_summary(config, self._telemetry_client) self.tts_client = self._make_tts_client(config) @staticmethod - def _make_turn_summary(config: VibeConfig) -> NoopTurnSummary | TurnSummaryTracker: + def _make_turn_summary( + config: VibeConfig, telemetry_client: TelemetryClient | None = None + ) -> NoopTurnSummary | TurnSummaryTracker: if not config.narrator_enabled: return NoopTurnSummary() result = create_narrator_backend(config) if result is None: return NoopTurnSummary() backend, model = result - return TurnSummaryTracker(backend=backend, model=model) + return TurnSummaryTracker( + backend=backend, + model=model, + session_metadata_getter=( + None + if telemetry_client is None + else telemetry_client.build_client_event_metadata + ), + ) @staticmethod def _make_tts_client(config: VibeConfig) -> TTSClientPort | None: diff --git a/vibe/cli/plan_offer/decide_plan_offer.py b/vibe/cli/plan_offer/decide_plan_offer.py index 458e58d..78b6918 100644 --- a/vibe/cli/plan_offer/decide_plan_offer.py +++ b/vibe/cli/plan_offer/decide_plan_offer.py @@ -58,6 +58,9 @@ class PlanInfo: def is_chat_pro_plan(self) -> bool: return self.plan_type == WhoAmIPlanType.CHAT + def is_teleport_eligible(self) -> bool: + return self.is_chat_pro_plan() and not self.prompt_switching_to_pro_plan + def is_free_mistral_code_plan(self) -> bool: return ( self.plan_type == WhoAmIPlanType.MISTRAL_CODE diff --git a/vibe/cli/stderr_guard.py b/vibe/cli/stderr_guard.py new file mode 100644 index 0000000..eb21c99 --- /dev/null +++ b/vibe/cli/stderr_guard.py @@ -0,0 +1,101 @@ +"""Guard against stray native writes to fd 2 corrupting the Textual TUI.""" + +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +import os +import sys + + +def _cleanup( + render_file: object | None, + render_fd: int | None, + saved_dunder: object, + saved_stderr: object, +) -> None: + """Restore sys.stderr / sys.__stderr__ and fd 2, ignoring errors.""" + sys.__stderr__ = saved_dunder # type: ignore[assignment] + sys.stderr = saved_stderr # type: ignore[assignment] + if render_file is not None: + try: + render_file.close() # type: ignore[union-attr] + except Exception: + pass + if render_fd is not None: + try: + os.dup2(render_fd, 2) + except Exception: + pass + finally: + try: + os.close(render_fd) + except Exception: + pass + + +@contextmanager +def stderr_guard() -> Generator[None]: + """Redirect OS-level fd 2 away from the terminal while keeping a + separate fd for Textual's rendering output. + + Textual renders to ``sys.__stderr__`` (fd 2) and captures Python-level + ``sys.stderr`` via ``contextlib.redirect_stderr``. Native C code (e.g. + macOS ``libsystem_malloc``) that calls ``write(2, …)`` bypasses that + redirect and corrupts the TUI. + + This guard: + + 1. Dups fd 2 to a new fd still pointing at the real terminal. + 2. Redirects fd 2 to ``/dev/null`` (absorbs stray native writes). + 3. Swaps ``sys.__stderr__`` / ``sys.stderr`` to a file object wrapping + the dup'd fd so Textual keeps rendering to the real terminal. + 4. Restores everything on exit. + + No-op on Windows or when fd 2 is not a tty. + """ + if sys.platform == "win32" or not _is_stderr_a_tty(): + yield + return + + render_fd: int | None = None + render_file = None + saved_dunder = sys.__stderr__ + saved_stderr = sys.stderr + + try: + render_fd = os.dup(2) + + devnull_fd = os.open(os.devnull, os.O_WRONLY) + try: + os.dup2(devnull_fd, 2) + finally: + os.close(devnull_fd) + + render_file = os.fdopen( + render_fd, "w", closefd=False, errors="backslashreplace" + ) + + sys.__stderr__ = render_file # type: ignore[assignment] + sys.stderr = render_file + except Exception: + _cleanup(render_file, render_fd, saved_dunder, saved_stderr) + yield + return + + try: + yield + finally: + try: + render_file.flush() + except Exception: + pass + + _cleanup(render_file, render_fd, saved_dunder, saved_stderr) + + +def _is_stderr_a_tty() -> bool: + try: + return os.isatty(2) + except OSError: + return False diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index be90905..4a13cc9 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from collections.abc import AsyncGenerator from contextlib import aclosing from dataclasses import dataclass from enum import StrEnum, auto @@ -25,8 +26,8 @@ from textual.widget import Widget from textual.widgets import Static from vibe import __version__ as CORE_VERSION -from vibe.cli.clipboard import copy_selection_to_clipboard -from vibe.cli.commands import CommandRegistry +from vibe.cli.clipboard import copy_selection_to_clipboard, copy_text_to_clipboard +from vibe.cli.commands import CommandAvailabilityContext, CommandRegistry from vibe.cli.narrator_manager import ( NarratorManager, NarratorManagerPort, @@ -47,6 +48,7 @@ from vibe.cli.textual_ui.notifications import ( NotificationPort, 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.session_exit import print_session_resume_message from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp @@ -58,10 +60,16 @@ from vibe.cli.textual_ui.widgets.config_app import ConfigApp from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState from vibe.cli.textual_ui.widgets.debug_console import DebugConsole from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar +from vibe.cli.textual_ui.widgets.feedback_bar_manager import FeedbackBarManager from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreRequested -from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer -from vibe.cli.textual_ui.widgets.mcp_app import MCPApp +from vibe.cli.textual_ui.widgets.loading import ( + DEFAULT_LOADING_STATUS, + LoadingWidget, + paused_timer, +) +from vibe.cli.textual_ui.widgets.mcp_app import MCPApp, MCPSourceKind from vibe.cli.textual_ui.widgets.messages import ( + AssistantMessage, BashOutputMessage, ErrorMessage, InterruptMessage, @@ -80,6 +88,7 @@ from vibe.cli.textual_ui.widgets.question_app import QuestionApp from vibe.cli.textual_ui.widgets.rewind_app import RewindApp from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage +from vibe.cli.textual_ui.widgets.thinking_picker import ThinkingPickerApp from vibe.cli.textual_ui.widgets.tools import ToolResultMessage from vibe.cli.textual_ui.widgets.voice_app import VoiceApp from vibe.cli.textual_ui.windowing import ( @@ -114,6 +123,7 @@ from vibe.core.audio_recorder import AudioRecorder from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt from vibe.core.config import VibeConfig from vibe.core.data_retention import DATA_RETENTION_MESSAGE +from vibe.core.hooks.models import HookStartEvent from vibe.core.log_reader import LogReader from vibe.core.logger import logger from vibe.core.paths import HISTORY_FILE @@ -144,14 +154,15 @@ from vibe.core.tools.builtins.ask_user_question import ( Choice, Question, ) -from vibe.core.tools.connectors import connectors_enabled +from vibe.core.tools.connectors import ConnectorRegistry, connectors_enabled +from vibe.core.tools.mcp_settings import persist_mcp_toggle from vibe.core.tools.permissions import RequiredPermission from vibe.core.transcribe import make_transcribe_client from vibe.core.types import ( AgentStats, ApprovalResponse, - Backend, BaseEvent, + ContextTooLongError, LLMMessage, RateLimitError, Role, @@ -164,6 +175,19 @@ from vibe.core.utils import ( ) +def _compute_connectors_count( + config: VibeConfig, connector_registry: ConnectorRegistry | None +) -> int: + total = connector_registry.connector_count if connector_registry else 0 + if total == 0: + return 0 + disabled_names = {c.name for c in config.connectors if c.disabled} + known_names = set( + connector_registry.get_connector_names() if connector_registry else [] + ) + return total - len(disabled_names & known_names) + + class BottomApp(StrEnum): """Bottom panel app types. @@ -179,6 +203,7 @@ class BottomApp(StrEnum): ModelPicker = auto() ProxySetup = auto() Question = auto() + ThinkingPicker = auto() Rewind = auto() SessionPicker = auto() Voice = auto() @@ -223,6 +248,7 @@ class ChatScroll(VerticalScroll): PRUNE_LOW_MARK = 1000 PRUNE_HIGH_MARK = 1500 +DOUBLE_ESC_DELAY = 0.2 async def prune_oldest_children( @@ -274,8 +300,8 @@ class VibeApp(App): # noqa: PLR0904 PAUSE_GC_ON_SCROLL: ClassVar[bool] = True BINDINGS: ClassVar[list[BindingType]] = [ - Binding("ctrl+c", "clear_quit", "Quit", show=False), - Binding("ctrl+d", "force_quit", "Quit", show=False, priority=True), + Binding("ctrl+c", "interrupt_or_quit", "Quit", show=False), + Binding("ctrl+d", "delete_right_or_quit", "Quit", show=False, priority=True), Binding("ctrl+z", "suspend_with_message", "Suspend", show=False, priority=True), Binding("escape", "interrupt", "Interrupt", show=False, priority=True), Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False), @@ -309,6 +335,7 @@ class VibeApp(App): # noqa: PLR0904 super().__init__(**kwargs) self.scroll_sensitivity_y = 1.0 self.agent_loop = agent_loop + self._plan_info: PlanInfo | None = None self._voice_manager: VoiceManagerPort = ( voice_manager or self._make_default_voice_manager() ) @@ -329,11 +356,6 @@ class VibeApp(App): # noqa: PLR0904 self.event_handler: EventHandler | None = None - excluded_commands = [] - if not self.config.nuage_enabled: - excluded_commands.append("teleport") - self.commands = CommandRegistry(excluded_commands=excluded_commands) - self._chat_input_container: ChatInputContainer | None = None self._current_bottom_app: BottomApp = BottomApp.Input @@ -352,9 +374,12 @@ class VibeApp(App): # noqa: PLR0904 self._plan_offer_gateway = plan_offer_gateway opts = startup or StartupOptions() self._initial_prompt = opts.initial_prompt - self._teleport_on_start = opts.teleport_on_start and self.config.nuage_enabled + self._teleport_on_start = ( + opts.teleport_on_start and self.agent_loop.base_config.vibe_code_enabled + ) self._show_resume_picker = opts.show_resume_picker self._last_escape_time: float | None = None + self._quit_manager = QuitManager(self) self._banner: Banner | None = None self._whats_new_message: WhatsNewMessage | None = None self._cached_messages_area: Widget | None = None @@ -363,7 +388,6 @@ class VibeApp(App): # noqa: PLR0904 self._log_reader = LogReader() self._debug_console: DebugConsole | None = None self._switch_agent_generation = 0 - self._plan_info: PlanInfo | None = None self._narrator_manager: NarratorManagerPort = ( narrator_manager or self._make_default_narrator_manager() ) @@ -371,6 +395,7 @@ class VibeApp(App): # noqa: PLR0904 self._rewind_mode = False self._rewind_highlighted_widget: UserMessage | None = None self._fatal_init_error = False + self.commands = self._build_command_registry() @property def config(self) -> VibeConfig: @@ -380,13 +405,30 @@ class VibeApp(App): # noqa: PLR0904 def _connectors_enabled(self) -> bool: return connectors_enabled() and self.agent_loop.connector_registry is not None + def _get_command_availability_context(self) -> CommandAvailabilityContext: + return CommandAvailabilityContext( + vibe_code_enabled=self.agent_loop.base_config.vibe_code_enabled, + is_active_model_mistral=self.config.is_active_model_mistral(), + plan_info=self._plan_info, + ) + + def _build_command_registry(self) -> CommandRegistry: + return CommandRegistry( + availability_context=self._get_command_availability_context() + ) + + def _refresh_command_registry(self) -> None: + self.commands.refresh(self._get_command_availability_context()) + def compose(self) -> ComposeResult: with ChatScroll(id="chat"): self._banner = Banner( config=self.config, skill_manager=self.agent_loop.skill_manager, mcp_registry=self.agent_loop.mcp_registry, - connector_registry=self.agent_loop.connector_registry, + connectors_count=_compute_connectors_count( + self.config, self.agent_loop.connector_registry + ), ) yield self._banner yield VerticalGroup(id="messages") @@ -405,7 +447,6 @@ class VibeApp(App): # noqa: PLR0904 agent_name=self.agent_loop.agent_profile.display_name.lower(), skill_entries_getter=self._get_skill_entries, file_watcher_for_autocomplete_getter=self._is_file_watcher_enabled, - nuage_enabled=self.config.nuage_enabled, voice_manager=self._voice_manager, ) @@ -422,6 +463,7 @@ class VibeApp(App): # noqa: PLR0904 self._cached_chat = self.query_one("#chat", ChatScroll) self._cached_loading_area = self.query_one("#loading-area-content") self._feedback_bar = self.query_one(FeedbackBar) + self._feedback_bar_manager = FeedbackBarManager() self.event_handler = EventHandler( mount_callback=self._mount_and_scroll, @@ -456,6 +498,7 @@ class VibeApp(App): # noqa: PLR0904 self.agent_loop.emit_new_session_telemetry() self.call_after_refresh(self._refresh_banner) + self._show_hook_config_issues_once() self.run_worker(self._watch_init_completion(), exclusive=False) @@ -467,12 +510,21 @@ class VibeApp(App): # noqa: PLR0904 gc.collect() gc.freeze() + def _show_hook_config_issues_once(self) -> None: + for issue in self.agent_loop.hook_config_issues: + self.notify( + f"{issue.file}\n{issue.message}", + severity="warning", + markup=False, + timeout=10, + ) + async def _watch_init_completion(self) -> None: """Show 'Initializing' loading indicator until background init finishes.""" init_widget = None try: if not self.agent_loop.is_initialized: - await self._ensure_loading_widget("Initializing") + await self._ensure_loading_widget("Initializing", show_hint=False) init_widget = self._loading_widget await self.agent_loop.wait_until_ready() except Exception as e: @@ -499,7 +551,7 @@ class VibeApp(App): # noqa: PLR0904 pass def _process_initial_prompt(self) -> None: - if self._teleport_on_start: + if self._teleport_on_start and self.commands.has_command("teleport"): self.run_worker( self._handle_teleport_command(self._initial_prompt), exclusive=False ) @@ -539,10 +591,9 @@ class VibeApp(App): # noqa: PLR0904 await self._handle_bash_command(value[1:]) return - if value.startswith("&"): - if self.config.nuage_enabled: - await self._handle_teleport_command(value[1:]) - return + if value.startswith("&") and self.commands.has_command("teleport"): + await self._handle_teleport_command(value[1:]) + return if await self._handle_command(value): return @@ -631,7 +682,20 @@ class VibeApp(App): # noqa: PLR0904 await self._switch_to_input_app() await self._switch_to_model_picker_app() - async def _ensure_loading_widget(self, status: str = "Generating") -> None: + async def on_config_app_open_thinking_picker( + self, _message: ConfigApp.OpenThinkingPicker + ) -> None: + config_app = self.query_one(ConfigApp) + changes = config_app._convert_changes_for_save() + if changes: + VibeConfig.save_updates(changes) + await self._reload_config() + await self._switch_to_input_app() + await self._switch_to_thinking_picker_app() + + async def _ensure_loading_widget( + self, status: str = DEFAULT_LOADING_STATUS, *, show_hint: bool = True + ) -> None: if self._loading_widget and self._loading_widget.parent: self._loading_widget.set_status(status) return @@ -642,7 +706,7 @@ class VibeApp(App): # noqa: PLR0904 loading_area = self.query_one("#loading-area-content") except Exception: return - loading = LoadingWidget(status=status) + loading = LoadingWidget(status=status, show_hint=show_hint) self._loading_widget = loading await loading_area.mount(loading) @@ -716,10 +780,34 @@ class VibeApp(App): # noqa: PLR0904 ) -> None: await self._switch_to_input_app() + async def on_thinking_picker_app_thinking_selected( + self, message: ThinkingPickerApp.ThinkingSelected + ) -> None: + self.config.set_thinking(message.level) + await self._reload_config() + await self._switch_to_input_app() + + async def on_thinking_picker_app_cancelled( + self, _event: ThinkingPickerApp.Cancelled + ) -> None: + await self._switch_to_input_app() + async def on_mcpapp_mcpclosed(self, _message: MCPApp.MCPClosed) -> None: await self._mount_and_scroll(UserCommandMessage("MCP servers closed.")) await self._switch_to_input_app() + async def on_mcpapp_mcptoggled(self, message: MCPApp.MCPToggled) -> None: + persist_mcp_toggle( + self.agent_loop.config, + name=message.name, + is_connector=message.kind == MCPSourceKind.CONNECTOR, + disabled=message.disabled, + tool_name=message.tool_name, + ) + self.agent_loop.refresh_config() + self.query_one(MCPApp).refresh_index() + self._refresh_banner() + async def on_proxy_setup_app_proxy_setup_closed( self, message: ProxySetupApp.ProxySetupClosed ) -> None: @@ -927,8 +1015,9 @@ class VibeApp(App): # noqa: PLR0904 user_message = UserMessage(message, message_index=message_index) await self._mount_and_scroll(user_message) - if self.agent_loop.telemetry_client.is_active(): - self._feedback_bar.maybe_show() + if self._feedback_bar_manager.should_show(self.agent_loop): + self._feedback_bar.show() + self._feedback_bar_manager.record_feedback_asked() if not self._agent_running: await self._remote_manager.stop_stream() @@ -1046,7 +1135,7 @@ class VibeApp(App): # noqa: PLR0904 ) -> tuple[ApprovalResponse, str | None]: # Auto-approve only if parent is in auto-approve mode AND tool is enabled # This ensures subagents respect the main agent's tool restrictions - if self.agent_loop and self.agent_loop.config.auto_approve: + if self.agent_loop and self.agent_loop.config.bypass_tool_permissions: if self._is_tool_enabled_in_main_agent(tool): return (ApprovalResponse.YES, None) @@ -1083,40 +1172,46 @@ class VibeApp(App): # noqa: PLR0904 if self.event_handler: self.event_handler.stop_current_tool_call(success=False) + async def _handle_agent_loop_init(self) -> None: + show_init_spinner = not self.agent_loop.is_initialized + if show_init_spinner: + await self._ensure_loading_widget("Initializing", show_hint=False) + await self.agent_loop.wait_until_ready() + if show_init_spinner: + await self._remove_loading_widget() + self._refresh_banner() + + async def _handle_agent_loop_events( + self, events: AsyncGenerator[BaseEvent] + ) -> None: + async for event in events: + 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): + await self._ensure_loading_widget() + if self.event_handler: + await self.event_handler.handle_event( + event, loading_widget=self._loading_widget + ) + async def _handle_agent_loop_turn(self, prompt: str) -> None: self._agent_running = True await self._remove_loading_widget() try: - show_init_spinner = not self.agent_loop.is_initialized - if show_init_spinner: - await self._ensure_loading_widget("Initializing") - await self.agent_loop.wait_until_ready() - if show_init_spinner: - await self._remove_loading_widget() - self._refresh_banner() - + await self._handle_agent_loop_init() await self._ensure_loading_widget() rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd()) self._narrator_manager.cancel() self._narrator_manager.on_turn_start(rendered_prompt) async with aclosing(self.agent_loop.act(rendered_prompt)) as events: - async for event in events: - 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 self._loading_widget is None and is_progress_event(event): - await self._ensure_loading_widget() - if self.event_handler: - await self.event_handler.handle_event( - event, - loading_active=self._loading_widget is not None, - loading_widget=self._loading_widget, - ) - + await self._handle_agent_loop_events(events) except asyncio.CancelledError: await self._handle_turn_error() self._narrator_manager.on_turn_cancel() @@ -1129,9 +1224,7 @@ class VibeApp(App): # noqa: PLR0904 if self._fatal_init_error: return - message = str(e) - if isinstance(e, RateLimitError): - message = self._rate_limit_message() + message = self._resolve_turn_error_message(e) self._narrator_manager.on_turn_error(message) await self._mount_and_scroll( @@ -1150,6 +1243,13 @@ class VibeApp(App): # noqa: PLR0904 await self._refresh_windowing_from_history() self._terminal_notifier.notify(NotificationContext.COMPLETE) + def _resolve_turn_error_message(self, e: Exception) -> str: + if isinstance(e, RateLimitError): + return self._rate_limit_message() + if isinstance(e, ContextTooLongError): + return self._context_too_long_message() + return str(e) + def _rate_limit_message(self) -> str: upgrade_to_pro = self._plan_info and ( self._plan_info.plan_type @@ -1160,6 +1260,16 @@ class VibeApp(App): # noqa: PLR0904 return "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access." return "Rate limits exceeded. Please wait a moment before trying again." + def _context_too_long_message(self) -> str: + return ( + "The conversation context exceeds the model's maximum limit. " + "The last messages and output of agent actions went above the allowed size.\n\n" + "To recover:\n" + "1. Use /rewind to undo recent messages and tool outputs\n" + "2. Then use /compact to summarize the remaining conversation\n\n" + "This will free up context space so you can continue working." + ) + async def _teleport_command(self, **kwargs: Any) -> None: await self._handle_teleport_command(show_message=False) @@ -1203,7 +1313,7 @@ class VibeApp(App): # noqa: PLR0904 return try: - gen = self.agent_loop.teleport_to_vibe_nuage(prompt) + gen = self.agent_loop.teleport_to_vibe_code(prompt) async for event in gen: match event: case TeleportCheckingGitEvent(): @@ -1316,6 +1426,29 @@ class VibeApp(App): # noqa: PLR0904 help_text = self.commands.get_help_text() await self._mount_and_scroll(UserCommandMessage(help_text)) + def _get_last_assistant_message_text(self) -> str | None: + messages_area = self._cached_messages_area or self.query_one("#messages") + for child in reversed(messages_area.children): + if not isinstance(child, AssistantMessage): + continue + if not (content := child.get_content().strip()): + continue + return content + return None + + async def _copy_last_agent_message(self, **kwargs: Any) -> None: + if (content := self._get_last_assistant_message_text()) is None: + self.notify( + "No agent message available to copy", severity="warning", timeout=3 + ) + return + + copied_text = copy_text_to_clipboard( + self, content, success_message="Last agent message copied to clipboard" + ) + if copied_text is not None: + self.agent_loop.telemetry_client.send_user_copied_text(copied_text) + async def _refresh_mcp_browser(self) -> str: await self.agent_loop.tool_manager.refresh_remote_tools_async() await self.agent_loop.refresh_system_prompt() @@ -1338,6 +1471,7 @@ class VibeApp(App): # noqa: PLR0904 ) await self._mount_and_scroll(UserCommandMessage(msg)) return + if self._current_bottom_app == BottomApp.MCP: return name = cmd_args.strip() @@ -1365,6 +1499,7 @@ class VibeApp(App): # noqa: PLR0904 tool_manager=self.agent_loop.tool_manager, initial_server=name, connector_registry=connector_registry, + get_connector_configs=lambda: self.agent_loop.config.connectors, refresh_callback=self._refresh_mcp_browser, ) ) @@ -1394,6 +1529,12 @@ class VibeApp(App): # noqa: PLR0904 return await self._switch_to_model_picker_app() + async def _show_thinking(self, **kwargs: Any) -> None: + """Switch to the thinking level picker in the bottom panel.""" + if self._current_bottom_app == BottomApp.ThinkingPicker: + return + await self._switch_to_thinking_picker_app() + async def _show_proxy_setup(self, **kwargs: Any) -> None: if self._current_bottom_app == BottomApp.ProxySetup: return @@ -1503,7 +1644,7 @@ class VibeApp(App): # noqa: PLR0904 f"Session `{short_session_id(session.session_id)}` not found." ) - loaded_messages, _ = SessionLoader.load_session(session_path) + loaded_messages, metadata = SessionLoader.load_session(session_path) if self._chat_input_container: self._chat_input_container.set_custom_border(None) @@ -1515,6 +1656,7 @@ class VibeApp(App): # noqa: PLR0904 ] self.agent_loop.session_id = session.session_id + self.agent_loop.parent_session_id = metadata.get("parent_session_id") self.agent_loop.session_logger.resume_existing_session( session.session_id, session_path ) @@ -1554,13 +1696,9 @@ class VibeApp(App): # noqa: PLR0904 self.event_handler.is_remote = True self._remote_manager.start_stream(self) - async def on_remote_event( - self, event: BaseEvent, loading_active: bool, loading_widget: Any - ) -> None: + async def on_remote_event(self, event: BaseEvent, loading_widget: Any) -> None: if self.event_handler: - await self.event_handler.handle_event( - event, loading_active=loading_active, loading_widget=loading_widget - ) + 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) @@ -1591,7 +1729,7 @@ class VibeApp(App): # noqa: PLR0904 async def remove_loading(self) -> None: await self._remove_loading_widget() - async def ensure_loading(self, status: str = "Generating") -> None: + async def ensure_loading(self, status: str = DEFAULT_LOADING_STATUS) -> None: await self._ensure_loading_widget(status) @property @@ -1613,7 +1751,9 @@ class VibeApp(App): # noqa: PLR0904 base_config, self.agent_loop.skill_manager, self.agent_loop.mcp_registry, - connector_registry=self.agent_loop.connector_registry, + connectors_count=_compute_connectors_count( + base_config, self.agent_loop.connector_registry + ), plan_description=plan_title(self._plan_info), ) await self._mount_and_scroll( @@ -1704,7 +1844,7 @@ class VibeApp(App): # noqa: PLR0904 ) ) - async def _compact_history(self, **kwargs: Any) -> None: + async def _compact_history(self, cmd_args: str = "", **kwargs: Any) -> None: if self._agent_running: await self._mount_and_scroll( ErrorMessage( @@ -1732,13 +1872,15 @@ class VibeApp(App): # noqa: PLR0904 await self._mount_and_scroll(compact_msg) self._agent_task = asyncio.create_task( - self._run_compact(compact_msg, old_tokens) + self._run_compact(compact_msg, old_tokens, cmd_args.strip()) ) - async def _run_compact(self, compact_msg: CompactMessage, old_tokens: int) -> None: + async def _run_compact( + self, compact_msg: CompactMessage, old_tokens: int, extra_instructions: str = "" + ) -> None: self._agent_running = True try: - await self.agent_loop.compact() + await self.agent_loop.compact(extra_instructions=extra_instructions) new_tokens = self.agent_loop.stats.context_tokens compact_msg.set_complete(old_tokens=old_tokens, new_tokens=new_tokens) @@ -1839,6 +1981,19 @@ class VibeApp(App): # noqa: PLR0904 ModelPickerApp(model_aliases=model_aliases, current_model=current_model) ) + async def _switch_to_thinking_picker_app(self) -> None: + if self._current_bottom_app == BottomApp.ThinkingPicker: + return + + from vibe.core.config import THINKING_LEVELS + + current_thinking = self.config.get_active_model().thinking + await self._switch_from_input( + ThinkingPickerApp( + thinking_levels=THINKING_LEVELS, current_thinking=current_thinking + ) + ) + async def _switch_to_proxy_setup_app(self) -> None: if self._current_bottom_app == BottomApp.ProxySetup: return @@ -1892,6 +2047,8 @@ class VibeApp(App): # noqa: PLR0904 self.query_one(ConfigApp).focus() case BottomApp.ModelPicker: self.query_one(ModelPickerApp).focus() + case BottomApp.ThinkingPicker: + self.query_one(ThinkingPickerApp).focus() case BottomApp.ProxySetup: self.query_one(ProxySetupApp).focus() case BottomApp.Approval: @@ -1953,6 +2110,14 @@ class VibeApp(App): # noqa: PLR0904 pass self._last_escape_time = None + def _handle_thinking_picker_app_escape(self) -> None: + try: + thinking_picker = self.query_one(ThinkingPickerApp) + thinking_picker.post_message(ThinkingPickerApp.Cancelled()) + except Exception: + pass + self._last_escape_time = None + def _handle_session_picker_app_escape(self) -> None: try: session_picker = self.query_one(SessionPickerApp) @@ -2178,73 +2343,76 @@ class VibeApp(App): # noqa: PLR0904 pass self._last_escape_time = None - def action_interrupt(self) -> None: # noqa: PLR0911 - if self._voice_manager.transcribe_state != TranscribeState.IDLE: - self._voice_manager.cancel_recording() - return - - current_time = time.monotonic() - + def _try_interrupt_bottom_app_escape(self) -> bool: if self._current_bottom_app == BottomApp.Config: self._handle_config_app_escape() - return - - if self._current_bottom_app == BottomApp.Voice: + elif self._current_bottom_app == BottomApp.Voice: self._handle_voice_app_escape() - return - - if self._current_bottom_app == BottomApp.MCP: + elif self._current_bottom_app == BottomApp.MCP: self._handle_bottom_app_close_escape(MCPApp) - return - - if self._current_bottom_app == BottomApp.ProxySetup: + elif self._current_bottom_app == BottomApp.ProxySetup: self._handle_bottom_app_close_escape(ProxySetupApp) - return - - if self._current_bottom_app == BottomApp.Approval: + elif self._current_bottom_app == BottomApp.Approval: self._handle_approval_app_escape() - return - - if self._current_bottom_app == BottomApp.Question: + elif self._current_bottom_app == BottomApp.Question: self._handle_question_app_escape() - return - - if self._current_bottom_app == BottomApp.ModelPicker: + elif self._current_bottom_app == BottomApp.ModelPicker: self._handle_model_picker_app_escape() - return - - if self._current_bottom_app == BottomApp.SessionPicker: + elif self._current_bottom_app == BottomApp.ThinkingPicker: + self._handle_thinking_picker_app_escape() + elif self._current_bottom_app == BottomApp.SessionPicker: self._handle_session_picker_app_escape() - return - - if self._current_bottom_app == BottomApp.Rewind: + elif self._current_bottom_app == BottomApp.Rewind: self.run_worker(self._exit_rewind_mode(), exclusive=False) self._last_escape_time = None - return - - if ( + elif ( self._current_bottom_app == BottomApp.Input and self._last_escape_time is not None - and (current_time - self._last_escape_time) < 0.2 # noqa: PLR2004 + and (time.monotonic() - self._last_escape_time) < DOUBLE_ESC_DELAY ): self._handle_input_app_escape() - return + else: + return False + return True + + def _try_interrupt(self) -> bool: + if self._voice_manager.transcribe_state != TranscribeState.IDLE: + self._voice_manager.cancel_recording() + return True + + if ( + self._chat_input_container + and self._chat_input_container.dismiss_completion() + ): + if self._chat_input_container.value.startswith("/"): + self._chat_input_container.value = "" + self._last_escape_time = None + return True + + if self._try_interrupt_bottom_app_escape(): + return True if ( self._narrator_manager.is_playing or self._narrator_manager.state != NarratorState.IDLE ): self._narrator_manager.cancel() - return + return True + interrupted = False if self._agent_running: self._handle_agent_running_escape() + interrupted = True - self._last_escape_time = current_time + self._last_escape_time = time.monotonic() chat = self._cached_chat or self.query_one("#chat", ChatScroll) if chat.is_at_bottom: self.call_after_refresh(chat.anchor) self._focus_current_bottom_app() + return interrupted + + def action_interrupt(self) -> None: + self._try_interrupt() async def on_history_load_more_requested(self, _: HistoryLoadMoreRequested) -> None: self._load_more.set_enabled(False) @@ -2311,7 +2479,9 @@ class VibeApp(App): # noqa: PLR0904 self.config, self.agent_loop.skill_manager, self.agent_loop.mcp_registry, - connector_registry=self.agent_loop.connector_registry, + connectors_count=_compute_connectors_count( + self.config, self.agent_loop.connector_registry + ), plan_description=plan_title(self._plan_info), ) @@ -2369,17 +2539,36 @@ class VibeApp(App): # noqa: PLR0904 self._debug_console = DebugConsole(log_reader=self._log_reader) await self.mount(self._debug_console) - def action_clear_quit(self) -> None: + def _get_chat_input(self) -> ChatInputContainer | None: input_widgets = self.query(ChatInputContainer) if input_widgets: - input_widget = input_widgets.first() - if input_widget.value: - input_widget.value = "" - return + return input_widgets.first() + return None - self.action_force_quit() + def action_interrupt_or_quit(self) -> None: + if (container := self._get_chat_input()) and container.value: + container.value = "" + return - def action_force_quit(self) -> None: + if self._quit_manager.is_confirmed("Ctrl+C"): + self._force_quit() + return + if self._try_interrupt(): + return + self._quit_manager.request_confirmation("Ctrl+C") + + def action_delete_right_or_quit(self) -> None: + if (container := self._get_chat_input()) and container.value: + if container.input_widget: + container.input_widget.action_delete_right() + return + + if self._quit_manager.is_confirmed("Ctrl+D"): + self._force_quit() + return + self._quit_manager.request_confirmation("Ctrl+D") + + def _force_quit(self) -> None: if self._agent_task and not self._agent_task.done(): self._agent_task.cancel() self._remote_manager.cancel_stream_task() @@ -2439,23 +2628,24 @@ class VibeApp(App): # noqa: PLR0904 async def _resolve_plan(self) -> None: if self._plan_offer_gateway is None: self._plan_info = None + self._refresh_command_registry() return try: - active_model = self.config.get_active_model() - provider = self.config.get_provider_for_model(active_model) - - if provider.backend != Backend.MISTRAL: + if not self.config.is_active_model_mistral(): self._plan_info = None return + provider = self.config.get_active_provider() api_key = resolve_api_key_for_plan(provider) self._plan_info = await decide_plan_offer(api_key, self._plan_offer_gateway) except Exception as exc: logger.warning( "Plan-offer check failed (%s).", type(exc).__name__, exc_info=True ) - return + self._plan_info = None + finally: + self._refresh_command_registry() async def _mount_and_scroll( self, widget: Widget, after: Widget | None = None @@ -2466,12 +2656,13 @@ class VibeApp(App): # noqa: PLR0904 is_user_initiated = isinstance(widget, (UserMessage, UserCommandMessage)) should_anchor = is_user_initiated or chat.is_at_bottom - if after is not None and after.parent is messages_area: - await messages_area.mount(widget, after=after) - else: - await messages_area.mount(widget) - if isinstance(widget, StreamingMessageBase): - await widget.write_initial_content() + with self.batch_update(): + if after is not None and after.parent is messages_area: + await messages_area.mount(widget, after=after) + else: + await messages_area.mount(widget) + if isinstance(widget, StreamingMessageBase): + await widget.write_initial_content() self.call_after_refresh(self._try_prune) if should_anchor: @@ -2600,15 +2791,20 @@ class VibeApp(App): # noqa: PLR0904 def run_textual_ui( agent_loop: AgentLoop, startup: StartupOptions | None = None ) -> None: + from vibe.cli.stderr_guard import stderr_guard + update_notifier = PyPIUpdateGateway(project_name="mistral-vibe") update_cache_repository = FileSystemUpdateCacheRepository() plan_offer_gateway = HttpWhoAmIGateway() - app = VibeApp( - agent_loop=agent_loop, - startup=startup, - update_notifier=update_notifier, - update_cache_repository=update_cache_repository, - plan_offer_gateway=plan_offer_gateway, - ) - session_id = app.run() + + 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, + ) + session_id = app.run() + print_session_resume_message(session_id, agent_loop.stats) diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss index 7b41537..3912416 100644 --- a/vibe/cli/textual_ui/app.tcss +++ b/vibe/cli/textual_ui/app.tcss @@ -88,13 +88,18 @@ TextArea > .text-area--cursor { constrain: inside inside; display: none; width: auto; - max-width: 80; height: auto; - max-height: 12; - padding: 0 1; color: ansi_default; background: $surface; border: solid ansi_bright_black; + overflow-y: auto; + scrollbar-size-vertical: 1; + /* max-height, max-width and padding set in completion_popup.py */ +} + +#completion-popup _CompletionItem { + height: auto; + width: 1fr; } #input-box { @@ -250,7 +255,7 @@ Markdown { padding: 0; margin: 0; - & > *:last-child { + & > MarkdownBlock:last-child { margin-bottom: 0; } } @@ -305,7 +310,7 @@ Markdown { text-style: italic; } - & > *:last-child { + & > MarkdownBlock:last-child { margin-bottom: 0; } } @@ -371,11 +376,11 @@ Markdown { margin: 0; padding: 0; - & > *:first-child { + & > MarkdownBlock:first-child { margin-top: 0; } - & > *:last-child { + & > MarkdownBlock:last-child { margin-bottom: 0; } } @@ -936,7 +941,7 @@ StatusMessage { padding: 0; margin: 0; - & > *:last-child { + & > MarkdownBlock:last-child { margin-bottom: 0; } } @@ -1034,11 +1039,11 @@ NarratorStatus { padding: 0 0 0 1; margin: 0; - & > *:first-child { + & > MarkdownBlock:first-child { margin-top: 0; } - & > *:last-child { + & > MarkdownBlock:last-child { margin-bottom: 0; } @@ -1164,6 +1169,45 @@ NarratorStatus { margin-top: 1; } +#thinkingpicker-app { + width: 100%; + height: auto; + background: transparent; + border: solid ansi_bright_black; + padding: 0 1; + margin: 0; +} + +#thinkingpicker-content { + width: 100%; + height: auto; +} + +.thinkingpicker-title { + height: auto; + text-style: bold; + color: ansi_blue; +} + +#thinkingpicker-options { + width: 100%; + max-height: 50vh; + text-wrap: nowrap; + text-overflow: ellipsis; + border: none; +} + +#thinkingpicker-options:focus { + border: none; +} + +.thinkingpicker-help { + width: 100%; + height: auto; + color: ansi_bright_black; + margin-top: 1; +} + FeedbackBar { width: auto; height: auto; @@ -1220,3 +1264,53 @@ FeedbackBar { height: auto; color: ansi_default; } + +.hook-system-message { + height: auto; + width: 100%; + margin-top: 0; + margin-bottom: 0; +} + +.hook-run-container { + height: auto; + width: 100%; + margin-top: 1; +} + +.hook-run-container:empty { + display: none; +} + +.hook-system-container { + width: 100%; + height: auto; + margin: 0; + padding: 0; +} + +.hook-system-icon { + width: auto; + height: 1; + padding: 0 1 0 0; +} + +.hook-system-content { + width: 1fr; + height: auto; + max-height: 3; + overflow: hidden; + color: ansi_bright_black; +} + +.hook-severity-ok .hook-system-icon { + color: ansi_green; +} + +.hook-severity-warning .hook-system-icon { + color: ansi_yellow; +} + +.hook-severity-error .hook-system-icon { + color: ansi_red; +} diff --git a/vibe/cli/textual_ui/handlers/event_handler.py b/vibe/cli/textual_ui/handlers/event_handler.py index 36d2000..b612bae 100644 --- a/vibe/cli/textual_ui/handlers/event_handler.py +++ b/vibe/cli/textual_ui/handlers/event_handler.py @@ -4,13 +4,23 @@ from collections.abc import Callable from typing import TYPE_CHECKING from vibe.cli.textual_ui.widgets.compact import CompactMessage +from vibe.cli.textual_ui.widgets.loading import DEFAULT_LOADING_STATUS from vibe.cli.textual_ui.widgets.messages import ( AssistantMessage, + HookRunContainer, + HookSystemMessageLine, ReasoningMessage, UserMessage, ) from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage +from vibe.core.hooks.models import ( + HookEndEvent, + HookEvent, + HookRunEndEvent, + HookRunStartEvent, + HookStartEvent, +) from vibe.core.tools.ui import ToolUIDataAdapter from vibe.core.types import ( AgentProfileChangedEvent, @@ -47,12 +57,36 @@ class EventHandler: self.current_compact: CompactMessage | None = None self.current_streaming_message: AssistantMessage | None = None self.current_streaming_reasoning: ReasoningMessage | None = None + self._hook_run_container: HookRunContainer | None = None + + async def _handle_hook_event( + self, event: HookEvent, loading_widget: LoadingWidget | None = None + ) -> None: + match event: + case HookRunStartEvent(): + self._hook_run_container = HookRunContainer() + await self.mount_callback(self._hook_run_container) + case HookRunEndEvent(): + if self._hook_run_container and not self._hook_run_container.display: + await self._hook_run_container.remove() + self._hook_run_container = None + case HookStartEvent(): + await self.finalize_streaming() + if loading_widget: + loading_widget.set_status(f"Running hook {event.hook_name}") + case HookEndEvent(): + if event.content and self._hook_run_container is not None: + widget = HookSystemMessageLine( + hook_name=event.hook_name, + content=event.content, + severity=event.status, + ) + await self._hook_run_container.add_message(widget) + if loading_widget: + loading_widget.set_status(DEFAULT_LOADING_STATUS) async def handle_event( - self, - event: BaseEvent, - loading_active: bool = False, - loading_widget: LoadingWidget | None = None, + self, event: BaseEvent, loading_widget: LoadingWidget | None = None ) -> ToolCallMessage | None: match event: case ReasoningEvent(): @@ -81,6 +115,8 @@ class EventHandler: 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 WaitingForInputEvent(): await self.finalize_streaming() case _: diff --git a/vibe/cli/textual_ui/quit_manager.py b/vibe/cli/textual_ui/quit_manager.py new file mode 100644 index 0000000..3df7fda --- /dev/null +++ b/vibe/cli/textual_ui/quit_manager.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import time +from typing import Literal + +from textual.app import App +from textual.timer import Timer + +from vibe.cli.textual_ui.widgets.path_display import PathDisplay + +QuitConfirmKey = Literal["Ctrl+C", "Ctrl+D"] + +QUIT_CONFIRM_DELAY = 1.0 + + +class QuitManager: + def __init__(self, app: App) -> None: + self._confirm_time: float | None = None + self._confirm_key: QuitConfirmKey | None = None + self._confirm_timer: Timer | None = None + self._app = app + + @property + def confirm_key(self) -> QuitConfirmKey | None: + return self._confirm_key + + def is_confirmed(self, key: QuitConfirmKey) -> bool: + return ( + self._confirm_time is not None + and self._confirm_key == key + and (time.monotonic() - self._confirm_time) < QUIT_CONFIRM_DELAY + ) + + def request_confirmation(self, key: QuitConfirmKey) -> None: + if self._confirm_timer is not None: + self._confirm_timer.stop() + self._confirm_timer = None + self._confirm_time = time.monotonic() + self._confirm_key = key + try: + path_display = self._app.query_one(PathDisplay) + path_display.update(f"Press {key} again to quit") + except Exception: + pass + self._confirm_timer = self._app.set_timer( + QUIT_CONFIRM_DELAY, self.cancel_confirmation + ) + + def cancel_confirmation(self) -> None: + if self._confirm_time is None: + return + self._confirm_time = None + self._confirm_key = None + if self._confirm_timer: + self._confirm_timer.stop() + self._confirm_timer = None + try: + path_display = self._app.query_one(PathDisplay) + path_display.refresh_display() + except Exception: + pass diff --git a/vibe/cli/textual_ui/remote/remote_session_manager.py b/vibe/cli/textual_ui/remote/remote_session_manager.py index a1d05e9..18a8fcb 100644 --- a/vibe/cli/textual_ui/remote/remote_session_manager.py +++ b/vibe/cli/textual_ui/remote/remote_session_manager.py @@ -3,6 +3,7 @@ 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 ( @@ -25,16 +26,14 @@ _MAX_QUESTION_OPTIONS = 4 class RemoteSessionUI(Protocol): - async def on_remote_event( - self, event: BaseEvent, loading_active: bool, loading_widget: Any - ) -> None: ... + 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 = "Generating") -> None: ... + async def ensure_loading(self, status: str = DEFAULT_LOADING_STATUS) -> None: ... @property def loading_widget(self) -> Any: ... @@ -182,7 +181,7 @@ class RemoteSessionManager: events_source = self._events_source if events_source is None: return - await ui.ensure_loading("Generating") + await ui.ensure_loading(DEFAULT_LOADING_STATUS) try: async for event in events_source.attach(): if isinstance(event, WaitingForInputEvent): @@ -197,11 +196,7 @@ class RemoteSessionManager: 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_active=ui.loading_widget is not None, - loading_widget=ui.loading_widget, - ) + await ui.on_remote_event(event, loading_widget=ui.loading_widget) except asyncio.CancelledError: raise except Exception as e: diff --git a/vibe/cli/textual_ui/widgets/banner/banner.py b/vibe/cli/textual_ui/widgets/banner/banner.py index 8094ec7..8c2cc4e 100644 --- a/vibe/cli/textual_ui/widgets/banner/banner.py +++ b/vibe/cli/textual_ui/widgets/banner/banner.py @@ -13,7 +13,6 @@ from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.config import VibeConfig from vibe.core.skills.manager import SkillManager -from vibe.core.tools.connectors import ConnectorRegistry from vibe.core.tools.mcp.registry import MCPRegistry @@ -21,10 +20,6 @@ def _pluralize(count: int, singular: str) -> str: return f"{count} {singular}{'s' if count != 1 else ''}" -def _connector_count(registry: ConnectorRegistry | None) -> int: - return registry.connector_count if registry else 0 - - @dataclass class BannerState: active_model: str = "" @@ -43,17 +38,16 @@ class Banner(Static): config: VibeConfig, skill_manager: SkillManager, mcp_registry: MCPRegistry, - connector_registry: ConnectorRegistry | None = None, + connectors_count: int = 0, **kwargs: Any, ) -> None: super().__init__(**kwargs) self.can_focus = False - self._initial_state = BannerState( - active_model=config.active_model, - models_count=len(config.models), - mcp_servers_count=mcp_registry.count_loaded(config.mcp_servers), - connectors_count=_connector_count(connector_registry), - skills_count=skill_manager.custom_skills_count, + self._initial_state = self._build_state( + config=config, + skill_manager=skill_manager, + mcp_registry=mcp_registry, + connectors_count=connectors_count, plan_description=None, ) self._animated = not config.disable_welcome_banner_animation @@ -97,14 +91,30 @@ class Banner(Static): config: VibeConfig, skill_manager: SkillManager, mcp_registry: MCPRegistry, - connector_registry: ConnectorRegistry | None = None, + connectors_count: int = 0, plan_description: str | None = None, ) -> None: - self.state = BannerState( - active_model=config.active_model, + self.state = self._build_state( + config, skill_manager, mcp_registry, connectors_count, plan_description + ) + + @staticmethod + def _build_state( + config: VibeConfig, + skill_manager: SkillManager, + mcp_registry: MCPRegistry, + connectors_count: int = 0, + plan_description: str | None = None, + ) -> BannerState: + enabled_servers = [s for s in config.mcp_servers if not s.disabled] + mcp_count = mcp_registry.count_loaded(enabled_servers) + + active_model = config.get_active_model() + return BannerState( + active_model=f"{active_model.alias}[{active_model.thinking}]", models_count=len(config.models), - mcp_servers_count=mcp_registry.count_loaded(config.mcp_servers), - connectors_count=_connector_count(connector_registry), + mcp_servers_count=mcp_count, + connectors_count=connectors_count, skills_count=skill_manager.custom_skills_count, plan_description=plan_description, ) diff --git a/vibe/cli/textual_ui/widgets/chat_input/body.py b/vibe/cli/textual_ui/widgets/chat_input/body.py index 4be7ebc..31bcff6 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/body.py +++ b/vibe/cli/textual_ui/widgets/chat_input/body.py @@ -10,6 +10,7 @@ from textual.message import Message from textual.widget import Widget from textual.widgets import Static +from vibe.cli.commands import CommandRegistry from vibe.cli.history_manager import HistoryManager from vibe.cli.textual_ui.recording.recording_indicator import RecordingIndicator from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea, InputMode @@ -44,15 +45,15 @@ class ChatInputBody(VoiceManagerListener, Widget): def __init__( self, + command_registry: CommandRegistry, history_file: Path | None = None, - nuage_enabled: bool = False, voice_manager: VoiceManagerPort | None = None, **kwargs: Any, ) -> None: super().__init__(**kwargs) self.input_widget: ChatTextArea | None = None self.prompt_widget: NoMarkupStatic | None = None - self._nuage_enabled = nuage_enabled + self._command_registry = command_registry self._switching_mode = False self._voice_manager = voice_manager self._recording_indicator: RecordingIndicator | None = None @@ -71,7 +72,7 @@ class ChatInputBody(VoiceManagerListener, Widget): self.input_widget = ChatTextArea( id="input", - nuage_enabled=self._nuage_enabled, + command_registry=self._command_registry, voice_manager=self._voice_manager, ) yield self.input_widget @@ -91,7 +92,7 @@ class ChatInputBody(VoiceManagerListener, Widget): return "!", text[1:] elif text.startswith("/"): return "/", text[1:] - elif text.startswith("&") and self._nuage_enabled: + elif text.startswith("&") and self._command_registry.has_command("teleport"): return "&", text[1:] else: return ">", text diff --git a/vibe/cli/textual_ui/widgets/chat_input/completion_manager.py b/vibe/cli/textual_ui/widgets/chat_input/completion_manager.py index d6eab56..142464e 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +++ b/vibe/cli/textual_ui/widgets/chat_input/completion_manager.py @@ -52,6 +52,10 @@ class MultiCompletionManager: return CompletionResult.IGNORED return self._active.on_key(event, text, cursor_index) + @property + def is_active(self) -> bool: + return self._active is not None + def reset(self) -> None: if self._active is not None: self._active.reset() 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 74d4677..4aa6000 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +++ b/vibe/cli/textual_ui/widgets/chat_input/completion_popup.py @@ -2,14 +2,27 @@ 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.widgets import Static +COMPLETION_POPUP_MAX_HEIGHT = 12 +COMPLETION_POPUP_MAX_WIDTH = 80 +COMPLETION_POPUP_PADDING_X = 1 -class CompletionPopup(Static): + +class _CompletionItem(Static): + pass + + +class CompletionPopup(VerticalScroll): def __init__(self, **kwargs: Any) -> None: - super().__init__("", id="completion-popup", **kwargs) + 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 def update_suggestions( @@ -19,11 +32,11 @@ class CompletionPopup(Static): self.hide() return - text = Text() - for idx, (label, description) in enumerate(suggestions): - if idx: - text.append("\n") + 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" @@ -32,14 +45,32 @@ class CompletionPopup(Static): text.append(" ") text.append(description, style=description_style) - self.update(text) + item = _CompletionItem(text) + items.append(item) + + self.mount_all(items) self.styles.display = "block" + if 0 <= selected < len(items): + items[selected].scroll_visible(animate=False) + def hide(self) -> None: - self.update("") + self.remove_children() self.styles.display = "none" - def _display_label(self, label: str) -> str: + @property + def content_text(self) -> str: + return "\n".join(str(child.render()) for child in self.query(_CompletionItem)) + + @staticmethod + def _display_label(label: str) -> str: 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 5b9fe8d..7c85929 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/container.py +++ b/vibe/cli/textual_ui/widgets/chat_input/container.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +import math from pathlib import Path from typing import Any @@ -15,7 +16,12 @@ 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 CompletionPopup +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.text_area import ChatTextArea from vibe.cli.voice_manager.voice_manager_port import VoiceManagerPort from vibe.core.agents import AgentSafety @@ -28,6 +34,12 @@ 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" @@ -39,26 +51,24 @@ class ChatInputContainer(Vertical): def __init__( self, + command_registry: CommandRegistry, history_file: Path | None = None, - command_registry: CommandRegistry | None = None, safety: AgentSafety = AgentSafety.NEUTRAL, agent_name: str = "", skill_entries_getter: Callable[[], list[tuple[str, str]]] | None = None, file_watcher_for_autocomplete_getter: Callable[[], bool] | None = None, - nuage_enabled: bool = False, voice_manager: VoiceManagerPort | None = None, **kwargs: Any, ) -> None: super().__init__(**kwargs) self._history_file = history_file - self._command_registry = command_registry or CommandRegistry() + self._command_registry = command_registry self._safety = safety self._agent_name = agent_name self._skill_entries_getter = skill_entries_getter self._file_watcher_for_autocomplete_getter = ( file_watcher_for_autocomplete_getter ) - self._nuage_enabled = nuage_enabled self._voice_manager = voice_manager self._custom_border_label: str | None = None self._custom_border_class: str | None = None @@ -92,8 +102,8 @@ class ChatInputContainer(Vertical): input_box.border_title = self._get_border_title() self._body = ChatInputBody( history_file=self._history_file, + command_registry=self._command_registry, id="input-body", - nuage_enabled=self._nuage_enabled, voice_manager=self._voice_manager, ) @@ -129,6 +139,12 @@ class ChatInputContainer(Vertical): widget.get_full_text(), widget._get_full_cursor_offset() ) + def dismiss_completion(self) -> bool: + if self._completion_manager.is_active: + self._completion_manager.reset() + return True + return False + def focus_input(self) -> None: if self._body: self._body.focus_input() @@ -141,7 +157,7 @@ class ChatInputContainer(Vertical): except Exception: return popup.update_suggestions(suggestions, selected_index) - self._position_popup(popup, len(suggestions)) + self._position_popup(popup, suggestions) def clear_completion_suggestions(self) -> None: try: @@ -150,18 +166,28 @@ class ChatInputContainer(Vertical): return popup.hide() - def _position_popup(self, popup: CompletionPopup, line_count: int) -> None: + 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 = line_count + 2 # +2 for solid border - popup.styles.offset = ( - cursor.x - my_region.x, - cursor.y - popup_height - my_region.y, - ) + 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. 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 835a260..02a3954 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/text_area.py +++ b/vibe/cli/textual_ui/widgets/chat_input/text_area.py @@ -8,6 +8,7 @@ from textual.message import Message from textual.widgets import TextArea from vibe.cli.autocompletion.base import CompletionResult +from vibe.cli.commands import CommandRegistry from vibe.cli.textual_ui.external_editor import ExternalEditor from vibe.cli.textual_ui.widgets.chat_input.completion_manager import ( MultiCompletionManager, @@ -59,12 +60,12 @@ class ChatTextArea(TextArea): def __init__( self, - nuage_enabled: bool = False, + command_registry: CommandRegistry, voice_manager: VoiceManagerPort | None = None, **kwargs: Any, ) -> None: super().__init__(**kwargs) - self._nuage_enabled = nuage_enabled + self._command_registry = command_registry self._input_mode: InputMode = self.DEFAULT_MODE self._last_text = "" self._navigating_history = False @@ -365,7 +366,7 @@ class ChatTextArea(TextArea): @property def mode_characters(self) -> set[InputMode]: chars: set[InputMode] = {"!", "/"} - if self._nuage_enabled: + if self._command_registry.has_command("teleport"): chars.add("&") return chars diff --git a/vibe/cli/textual_ui/widgets/config_app.py b/vibe/cli/textual_ui/widgets/config_app.py index 536f77f..a608591 100644 --- a/vibe/cli/textual_ui/widgets/config_app.py +++ b/vibe/cli/textual_ui/widgets/config_app.py @@ -1,5 +1,6 @@ from __future__ import annotations +from enum import StrEnum, auto from typing import TYPE_CHECKING, ClassVar from rich.text import Text @@ -17,6 +18,23 @@ if TYPE_CHECKING: from vibe.core.config import VibeConfig +class ConfigOptionKind(StrEnum): + ACTION_MODEL = auto() + ACTION_THINKING = auto() + + @staticmethod + def toggle(key: str) -> str: + return f"toggle:{key}" + + @staticmethod + def is_toggle(option_id: str) -> bool: + return option_id.startswith("toggle:") + + @staticmethod + def toggle_key(option_id: str) -> str: + return option_id.removeprefix("toggle:") + + class ConfigApp(Container): """Settings panel with navigatable option picker.""" @@ -40,6 +58,9 @@ class ConfigApp(Container): class OpenModelPicker(Message): pass + class OpenThinkingPicker(Message): + pass + def __init__(self, config: VibeConfig) -> None: super().__init__(id="config-app") self.config = config @@ -69,6 +90,18 @@ class ConfigApp(Container): text.append(self._get_current_model(), style="bold") return text + def _get_current_thinking(self) -> str: + try: + return str(self.config.get_active_model().thinking) + except ValueError: + return "off" + + def _thinking_prompt(self) -> Text: + text = Text(no_wrap=True) + text.append("Thinking: ") + text.append(self._get_current_thinking().capitalize(), style="bold") + return text + def _toggle_prompt(self, key: str, label: str) -> Text: value = self._get_toggle_value(key) text = Text(no_wrap=True) @@ -80,9 +113,14 @@ class ConfigApp(Container): return text def compose(self) -> ComposeResult: - options: list[Option] = [Option(self._model_prompt(), id="action:active_model")] + options: list[Option] = [ + Option(self._model_prompt(), id=ConfigOptionKind.ACTION_MODEL), + Option(self._thinking_prompt(), id=ConfigOptionKind.ACTION_THINKING), + ] for key, label in self._toggle_settings: - options.append(Option(self._toggle_prompt(key, label), id=f"toggle:{key}")) + options.append( + Option(self._toggle_prompt(key, label), id=ConfigOptionKind.toggle(key)) + ) with Vertical(id="config-content"): yield NoMarkupStatic("Settings", classes="settings-title") @@ -101,10 +139,15 @@ class ConfigApp(Container): def _refresh_options(self) -> None: option_list = self.query_one(OptionList) - option_list.replace_option_prompt("action:active_model", self._model_prompt()) + option_list.replace_option_prompt( + ConfigOptionKind.ACTION_MODEL, self._model_prompt() + ) + option_list.replace_option_prompt( + ConfigOptionKind.ACTION_THINKING, self._thinking_prompt() + ) for key, label in self._toggle_settings: option_list.replace_option_prompt( - f"toggle:{key}", self._toggle_prompt(key, label) + ConfigOptionKind.toggle(key), self._toggle_prompt(key, label) ) def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: @@ -112,12 +155,16 @@ class ConfigApp(Container): if not option_id: return - if option_id == "action:active_model": + if option_id == ConfigOptionKind.ACTION_MODEL: self.post_message(self.OpenModelPicker()) return - if option_id.startswith("toggle:"): - key = option_id.removeprefix("toggle:") + if option_id == ConfigOptionKind.ACTION_THINKING: + self.post_message(self.OpenThinkingPicker()) + return + + if ConfigOptionKind.is_toggle(option_id): + key = ConfigOptionKind.toggle_key(option_id) current = self._get_toggle_value(key) new_value = "Off" if current == "On" else "On" self.changes[key] = new_value diff --git a/vibe/cli/textual_ui/widgets/feedback_bar.py b/vibe/cli/textual_ui/widgets/feedback_bar.py index ba5e4a4..7d1a384 100644 --- a/vibe/cli/textual_ui/widgets/feedback_bar.py +++ b/vibe/cli/textual_ui/widgets/feedback_bar.py @@ -1,7 +1,5 @@ from __future__ import annotations -import random - from rich.text import Text from textual.app import ComposeResult from textual.message import Message @@ -10,7 +8,6 @@ from textual.widgets import Static from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea -FEEDBACK_PROBABILITY = 0.02 THANK_YOU_DURATION = 2.0 @@ -38,10 +35,8 @@ class FeedbackBar(Widget): def on_mount(self) -> None: self.display = False - def maybe_show(self) -> None: - if self.display: - return - if random.random() <= FEEDBACK_PROBABILITY: + def show(self) -> None: + if not self.display: self._set_active(True) def hide(self) -> None: diff --git a/vibe/cli/textual_ui/widgets/feedback_bar_manager.py b/vibe/cli/textual_ui/widgets/feedback_bar_manager.py new file mode 100644 index 0000000..eeb54b9 --- /dev/null +++ b/vibe/cli/textual_ui/widgets/feedback_bar_manager.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import random +import time + +from vibe.cli.cache import read_cache, write_cache +from vibe.core.agent_loop import AgentLoop +from vibe.core.paths import CACHE_FILE +from vibe.core.types import Role + +FEEDBACK_PROBABILITY = 0.2 +FEEDBACK_COOLDOWN_SECONDS = 3600 +_CACHE_SECTION = "user_feedback" +_LAST_SHOWN_KEY = "last_shown_at" +MIN_USER_MESSAGES_FOR_FEEDBACK = 3 + + +class FeedbackBarManager: + """Decides whether to show the feedback bar and records when feedback is given.""" + + def should_show(self, agent_loop: AgentLoop) -> bool: + if not agent_loop.telemetry_client.is_active(): + return False + + if not agent_loop.config.is_active_model_mistral(): + return False + + if ( + sum(m.role == Role.user and not m.injected for m in agent_loop.messages) + + 1 # +1 for the message the user just sent + < MIN_USER_MESSAGES_FOR_FEEDBACK + ): + return False + + last_ts = ( + read_cache(CACHE_FILE.path).get(_CACHE_SECTION, {}).get(_LAST_SHOWN_KEY, 0) + ) + if not isinstance(last_ts, int): + return False + + return ( + time.time() - last_ts >= FEEDBACK_COOLDOWN_SECONDS + and random.random() <= FEEDBACK_PROBABILITY + ) + + def record_feedback_asked(self) -> None: + write_cache( + CACHE_FILE.path, _CACHE_SECTION, {_LAST_SHOWN_KEY: int(time.time())} + ) diff --git a/vibe/cli/textual_ui/widgets/loading.py b/vibe/cli/textual_ui/widgets/loading.py index 6c7982b..a3af2dd 100644 --- a/vibe/cli/textual_ui/widgets/loading.py +++ b/vibe/cli/textual_ui/widgets/loading.py @@ -15,6 +15,8 @@ from vibe.cli.textual_ui.constants import MistralColors from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType +DEFAULT_LOADING_STATUS = "Generating" + def _format_elapsed(seconds: int) -> str: if seconds < 60: # noqa: PLR2004 @@ -71,7 +73,7 @@ class LoadingWidget(SpinnerMixin, Static): "Writing holiday cards", ] - def __init__(self, status: str | None = None) -> None: + def __init__(self, status: str | None = None, *, show_hint: bool = True) -> None: super().__init__(classes="loading-widget") self.init_spinner() self.status = status or self._get_default_status() @@ -81,6 +83,7 @@ class LoadingWidget(SpinnerMixin, Static): self._indicator_widget: Static | None = None self._status_widget: Static | None = None self.hint_widget: Static | None = None + self._show_hint = show_hint self.start_time: float | None = None self._last_elapsed: int = -1 self._paused_total: float = 0.0 @@ -104,7 +107,7 @@ class LoadingWidget(SpinnerMixin, Static): return None def _get_default_status(self) -> str: - return self._get_easter_egg() or "Generating" + return self._get_easter_egg() or DEFAULT_LOADING_STATUS def _apply_easter_egg(self, status: str) -> str: return self._get_easter_egg() or status @@ -120,7 +123,8 @@ class LoadingWidget(SpinnerMixin, Static): def set_status(self, status: str) -> None: self.status = self._apply_easter_egg(status) - self._update_animation() + if self._status_widget: + self._status_widget.update(self._build_status_text()) def compose(self) -> ComposeResult: with Horizontal(classes="loading-container"): @@ -132,10 +136,11 @@ class LoadingWidget(SpinnerMixin, Static): self._status_widget = Static("", classes="loading-status") yield self._status_widget - self.hint_widget = NoMarkupStatic( - "(0s esc to interrupt)", classes="loading-hint" - ) - yield self.hint_widget + if self._show_hint: + self.hint_widget = NoMarkupStatic( + "(0s Esc/Ctrl+C to interrupt)", classes="loading-hint" + ) + yield self.hint_widget def on_mount(self) -> None: self.start_time = time() @@ -196,7 +201,7 @@ class LoadingWidget(SpinnerMixin, Static): if elapsed != self._last_elapsed: self._last_elapsed = elapsed self.hint_widget.update( - f"({_format_elapsed(elapsed)} esc to interrupt)" + f"({_format_elapsed(elapsed)} Esc/Ctrl+C to interrupt)" ) diff --git a/vibe/cli/textual_ui/widgets/mcp_app.py b/vibe/cli/textual_ui/widgets/mcp_app.py index fb5190f..b066c9b 100644 --- a/vibe/cli/textual_ui/widgets/mcp_app.py +++ b/vibe/cli/textual_ui/widgets/mcp_app.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Sequence +from enum import StrEnum, auto from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple from rich.text import Text @@ -10,18 +11,25 @@ from textual.containers import Container, Vertical from textual.events import DescendantBlur from textual.message import Message from textual.widgets import OptionList -from textual.widgets.option_list import Option +from textual.widgets.option_list import Option, OptionDoesNotExist from textual.worker import Worker from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic +from vibe.core.config import ConnectorConfig from vibe.core.tools.connectors import ConnectorRegistry, connectors_enabled from vibe.core.tools.mcp.tools import MCPTool +from vibe.core.tools.mcp_settings import updated_tool_list if TYPE_CHECKING: from vibe.core.config import MCPServer from vibe.core.tools.manager import ToolManager +class MCPSourceKind(StrEnum): + SERVER = auto() + CONNECTOR = auto() + + class MCPToolIndex(NamedTuple): server_tools: dict[str, list[tuple[str, type[MCPTool]]]] connector_tools: dict[str, list[tuple[str, type[MCPTool]]]] @@ -54,38 +62,69 @@ def collect_mcp_tool_index( return MCPToolIndex(server_tools, connector_tools, enabled_tools=available) +_LIST_VIEW_HELP = ( + "↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close" +) +_DETAIL_VIEW_HELP = ( + "↑↓ Navigate D Disable E Enable Backspace Back R Refresh Esc Close" +) + + class MCPApp(Container): can_focus_children = True BINDINGS: ClassVar[list[BindingType]] = [ Binding("escape", "close", "Close", show=False), Binding("backspace", "back", "Back", show=False), + Binding("d", "disable", "Disable", show=False), + Binding("e", "enable", "Enable", show=False), Binding("r", "refresh", "Refresh", show=False), ] class MCPClosed(Message): pass + class MCPToggled(Message): + """Posted when a server/connector or individual tool is toggled.""" + + def __init__( + self, + name: str, + kind: MCPSourceKind, + disabled: bool, + tool_name: str | None = None, + ) -> None: + super().__init__() + self.name = name + self.kind = kind + self.disabled = disabled + self.tool_name = tool_name + def __init__( self, mcp_servers: Sequence[MCPServer], tool_manager: ToolManager, initial_server: str = "", connector_registry: ConnectorRegistry | None = None, + get_connector_configs: Callable[[], list[ConnectorConfig]] | None = None, refresh_callback: Callable[[], Awaitable[str]] | None = None, ) -> None: super().__init__(id="mcp-app") self._mcp_servers = mcp_servers self._connector_registry = connector_registry + self._get_connector_configs = get_connector_configs or (lambda: []) connector_names = ( connector_registry.get_connector_names() if connector_registry else [] ) self._connector_names = connector_names + self._sorted_connector_names = _sort_connector_names_for_menu( + connector_names, connector_registry + ) self._tool_manager = tool_manager self._index = collect_mcp_tool_index(mcp_servers, tool_manager, connector_names) - # Track both the name and the kind ("server" or "connector") to - # disambiguate entries that share the same normalised name. + # Track both the name and the kind to disambiguate entries that + # share the same normalised name. self._viewing_server: str | None = initial_server.strip() or None - self._viewing_kind: str | None = None + self._viewing_kind: MCPSourceKind | None = None self._refresh_callback = refresh_callback self._status_message: str | None = None self._refreshing = False @@ -106,10 +145,10 @@ class MCPApp(Container): """Re-snapshot the tool index (e.g. after deferred MCP discovery).""" if self._connector_registry: self._connector_names = self._connector_registry.get_connector_names() - self._index = collect_mcp_tool_index( - self._mcp_servers, self._tool_manager, self._connector_names - ) - self._refresh_view(self._viewing_server, kind=self._viewing_kind) + self._sorted_connector_names = _sort_connector_names_for_menu( + self._connector_names, self._connector_registry + ) + self._rebuild_preserving_scroll() def on_descendant_blur(self, _event: DescendantBlur) -> None: self.query_one(OptionList).focus() @@ -117,9 +156,25 @@ class MCPApp(Container): def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: option_id = event.option.id or "" if option_id.startswith("server:"): - self._refresh_view(option_id.removeprefix("server:"), kind="server") + self._refresh_view( + option_id.removeprefix("server:"), kind=MCPSourceKind.SERVER + ) elif option_id.startswith("connector:"): - self._refresh_view(option_id.removeprefix("connector:"), kind="connector") + self._refresh_view( + option_id.removeprefix("connector:"), kind=MCPSourceKind.CONNECTOR + ) + + def on_option_list_option_highlighted( + self, event: OptionList.OptionHighlighted + ) -> None: + option_list = self.query_one(OptionList) + highlighted = option_list.highlighted + if highlighted is None or highlighted == 0: + return + # When the first enabled option is highlighted and all options above + # it are disabled headers, scroll to top so the header stays visible. + if all(option_list.get_option_at_index(i).disabled for i in range(highlighted)): + option_list.scroll_to(y=0, animate=False, force=True, immediate=True) def action_back(self) -> None: if self._refreshing: @@ -137,13 +192,15 @@ class MCPApp(Container): return self._status_message = "Refreshing..." - self._refresh_view(self._viewing_server, kind=self._viewing_kind) + help = _DETAIL_VIEW_HELP if self._viewing_server else _LIST_VIEW_HELP + self._set_help_text(help) self._refreshing = True self.run_worker(self._run_refresh(), exclusive=True, group="refresh") async def _run_refresh(self) -> str: - assert self._refresh_callback is not None + if self._refresh_callback is None: + return "" return await self._refresh_callback() def on_worker_state_changed(self, event: Worker.StateChanged) -> None: @@ -160,10 +217,147 @@ class MCPApp(Container): text = f"{self._status_message} {text}" self.query_one("#mcp-help", NoMarkupStatic).update(text) + def _find_connector_config(self, name: str) -> ConnectorConfig | None: + """Look up a connector config by name from the live VibeConfig source.""" + return next((c for c in self._get_connector_configs() if c.name == name), None) + + def action_disable(self) -> None: + self._set_highlighted_disabled(disabled=True) + + def action_enable(self) -> None: + self._set_highlighted_disabled(disabled=False) + + def _set_highlighted_disabled(self, *, disabled: bool) -> None: + """Set the disabled state for the highlighted server/connector or tool.""" + # In detail view, set the individual tool state, not the parent. + if self._viewing_server is not None and self._viewing_kind is not None: + self._set_highlighted_tool_disabled(disabled=disabled) + return + + target = self._get_highlighted_target() + if target is None: + return + name, kind = target + + if kind == MCPSourceKind.SERVER: + for srv in self._mcp_servers: + if srv.name == name: + srv.disabled = disabled + break + else: + cfg = self._find_connector_config(name) + if cfg is None: + cfg = ConnectorConfig(name=name, disabled=disabled) + self._get_connector_configs().append(cfg) + else: + cfg.disabled = disabled + + self.post_message(self.MCPToggled(name=name, kind=kind, disabled=disabled)) + self._rebuild_preserving_scroll() + + def _set_highlighted_tool_disabled(self, *, disabled: bool) -> None: + """Toggle a single tool inside a detail view.""" + server_name = self._viewing_server + kind = self._viewing_kind + if server_name is None or kind is None: + return + + option_list = self.query_one(OptionList) + highlighted = option_list.highlighted + if highlighted is None: + return + option = option_list.get_option_at_index(highlighted) + option_id = option.id or "" + if not option_id.startswith("tool:"): + return + full_tool_name = option_id.removeprefix("tool:") + + # Look up the remote name from the index. + tools_source = ( + self._index.connector_tools + if kind == MCPSourceKind.CONNECTOR + else self._index.server_tools + ) + remote_name: str | None = None + for t_name, cls in tools_source.get(server_name, []): + if t_name == full_tool_name: + remote_name = cls.get_remote_name() + break + if remote_name is None: + return + + # Update disabled_tools on the config object. + if kind == MCPSourceKind.SERVER: + for srv in self._mcp_servers: + if srv.name == server_name: + srv.disabled_tools = updated_tool_list( + srv.disabled_tools, remote_name, disabled + ) + break + else: + cfg = self._find_connector_config(server_name) + if cfg is None: + cfg = ConnectorConfig( + name=server_name, disabled_tools=[remote_name] if disabled else [] + ) + self._get_connector_configs().append(cfg) + else: + cfg.disabled_tools = updated_tool_list( + cfg.disabled_tools, remote_name, disabled + ) + + self.post_message( + self.MCPToggled( + name=server_name, kind=kind, disabled=disabled, tool_name=remote_name + ) + ) + self._rebuild_preserving_scroll() + + def _rebuild_preserving_scroll(self) -> None: + """Rebuild the tool index and refresh the view, preserving highlight and scroll.""" + option_list = self.query_one(OptionList) + saved_option_id: str | None = None + if (idx := option_list.highlighted) is not None: + saved_option_id = option_list.get_option_at_index(idx).id + saved_scroll_y = option_list.scroll_offset.y + + self._index = collect_mcp_tool_index( + self._mcp_servers, self._tool_manager, self._connector_names + ) + self._refresh_view(self._viewing_server, kind=self._viewing_kind) + + if saved_option_id is not None: + try: + new_index = option_list.get_option_index(saved_option_id) + option_list.highlighted = new_index + except OptionDoesNotExist: + pass + option_list.scroll_to( + y=saved_scroll_y, animate=False, force=True, immediate=True + ) + + def _get_highlighted_target(self) -> tuple[str, MCPSourceKind] | None: + """Return (name, kind) for the currently highlighted option, or None.""" + # If we're inside a detail view, use the viewed server/connector. + if self._viewing_server is not None and self._viewing_kind is not None: + return self._viewing_server, self._viewing_kind + + option_list = self.query_one(OptionList) + highlighted = option_list.highlighted + if highlighted is None: + return None + option = option_list.get_option_at_index(highlighted) + option_id = option.id or "" + if option_id.startswith("server:"): + return option_id.removeprefix("server:"), MCPSourceKind.SERVER + if option_id.startswith("connector:"): + return option_id.removeprefix("connector:"), MCPSourceKind.CONNECTOR + return None + # ── list view ──────────────────────────────────────────────────── def _refresh_view( - self, server_name: str | None, *, kind: str | None = None + self, server_name: str | None, *, kind: MCPSourceKind | None = None ) -> None: index = self._index option_list = self.query_one(OptionList) @@ -179,9 +373,9 @@ class MCPApp(Container): # Prefer server over connector when the name is ambiguous. if kind is None: if server_name in server_names: - kind = "server" + kind = MCPSourceKind.SERVER else: - kind = "connector" + kind = MCPSourceKind.CONNECTOR self._show_detail_view(server_name, option_list, index, kind=kind) @@ -191,37 +385,16 @@ class MCPApp(Container): has_connectors = connectors_enabled() and bool(self._connector_names) title = "MCP Servers & Connectors" if has_connectors else "MCP Servers" self.query_one("#mcp-title", NoMarkupStatic).update(title) - self._set_help_text("↑↓ Navigate Enter Show tools R Refresh Esc Close") + self._set_help_text(_LIST_VIEW_HELP) has_servers = bool(self._mcp_servers) - # ── Local MCP Servers ── if has_servers: - max_name = max(len(srv.name) for srv in self._mcp_servers) - max_type = max(len(srv.transport) + 2 for srv in self._mcp_servers) # +[] - option_list.add_option( - Option( - Text("Local MCP Servers", style="bold", no_wrap=True), disabled=True - ) - ) - for srv in self._mcp_servers: - tools = index.server_tools.get(srv.name, []) - enabled = sum(1 for t, _ in tools if t in index.enabled_tools) - type_tag = f"[{srv.transport}]" - label = Text(no_wrap=True) - label.append(f" {srv.name:<{max_name}}") - label.append(f" {type_tag:<{max_type}}", style="dim") - label.append(f" {_tool_count_text(enabled)}", style="dim") - option_list.add_option(Option(label, id=f"server:{srv.name}")) - - # ── Workspace Connectors ── + self._list_mcp_servers(option_list, index) if has_connectors: if has_servers: option_list.add_option(Option(Text("", no_wrap=True), disabled=True)) - self._add_connector_options( - option_list, index, self._connector_names, self._connector_registry - ) - + self._list_connectors(option_list=option_list, index=index) if not has_servers and not has_connectors: empty_msg = ( "No MCP servers or connectors configured" @@ -233,50 +406,65 @@ class MCPApp(Container): if has_servers or has_connectors: # Skip disabled header options (e.g. section labels). first_enabled = next( - (i for i, opt in enumerate(option_list._options) if not opt.disabled), 0 + (i for i, opt in enumerate(option_list.options) if not opt.disabled), 0 ) option_list.highlighted = first_enabled - def _add_connector_options( - self, - option_list: OptionList, - index: MCPToolIndex, - connector_names: Sequence[str], - connector_registry: ConnectorRegistry | None, - ) -> None: - ordered_connector_names = _sort_connector_names_for_menu( - connector_names, connector_registry + def _list_mcp_servers(self, option_list: OptionList, index: MCPToolIndex) -> None: + max_name = max(len(srv.name) for srv in self._mcp_servers) + max_type = max(len(srv.transport) + 2 for srv in self._mcp_servers) + option_list.add_option( + Option(Text("Local MCP Servers", style="bold", no_wrap=True), disabled=True) ) - max_name = max(len(name) for name in ordered_connector_names) + for srv in self._mcp_servers: + tools = index.server_tools.get(srv.name, []) + total = len(tools) + enabled = sum(1 for t, _ in tools if t in index.enabled_tools) + type_tag = f"[{srv.transport}]" + label = Text(no_wrap=True) + label.append(f" {srv.name:<{max_name}}") + label.append(f" {type_tag:<{max_type}}", style="dim") + label.append(f" {_tool_count_text(enabled, total)}", style="dim") + if srv.disabled: + label.append(" ") + label.append("○", style="dim") + label.append(" disabled", style="dim") + option_list.add_option(Option(label, id=f"server:{srv.name}")) + + def _list_connectors(self, option_list: OptionList, index: MCPToolIndex) -> None: + ordered_connector_names = self._sorted_connector_names + max_name = max(len(n) for n in ordered_connector_names) type_tag = "[connector]" type_width = len(type_tag) - tool_texts = { - name: _tool_count_text( - sum( - 1 - for tool_name, _ in index.connector_tools.get(name, []) - if tool_name in index.enabled_tools - ) - ) - for name in ordered_connector_names - } - max_tools = max(len(text) for text in tool_texts.values()) + tool_texts: dict[str, str] = {} + for n in ordered_connector_names: + tools = index.connector_tools.get(n, []) + total = len(tools) + enabled = sum(1 for t, _ in tools if t in index.enabled_tools) + tool_texts[n] = _tool_count_text(enabled, total) + max_tools = max(len(t) for t in tool_texts.values()) option_list.add_option( Option( Text("Workspace Connectors", style="bold", no_wrap=True), disabled=True ) ) - for connector_name in ordered_connector_names: + for cname in ordered_connector_names: + cfg = self._find_connector_config(cname) + is_disabled = cfg.disabled if cfg else False connected = ( - connector_registry.is_connected(connector_name) - if connector_registry + self._connector_registry.is_connected(cname) + if self._connector_registry else False ) label = Text(no_wrap=True) - label.append(f" {connector_name:<{max_name}}") + label.append(f" {cname:<{max_name}}") label.append(f" {type_tag:<{type_width}}", style="dim") - label.append(f" {tool_texts[connector_name]:<{max_tools}}", style="dim") - if connected: + label.append(f" {tool_texts[cname]:<{max_tools}}", style="dim") + if is_disabled: + label.append(" ") + label.append("○", style="dim") + label.append(" disabled", style="dim") + elif connected: label.append(" ") label.append("●", style="green") label.append(" connected", style="dim") @@ -284,7 +472,7 @@ class MCPApp(Container): label.append(" ") label.append("○", style="dim") label.append(" not connected", style="dim") - option_list.add_option(Option(label, id=f"connector:{connector_name}")) + option_list.add_option(Option(label, id=f"connector:{cname}")) # ── detail view ────────────────────────────────────────────────── @@ -294,43 +482,51 @@ class MCPApp(Container): option_list: OptionList, index: MCPToolIndex, *, - kind: str = "server", + kind: MCPSourceKind = MCPSourceKind.SERVER, ) -> None: self._viewing_server = server_name self._viewing_kind = kind - is_connector = kind == "connector" + is_connector = kind == MCPSourceKind.CONNECTOR title_prefix = "Connector" if is_connector else "MCP Server" self.query_one("#mcp-title", NoMarkupStatic).update( f"{title_prefix}: {server_name}" ) - self._set_help_text("↑↓ Navigate Backspace Back R Refresh Esc Close") + self._set_help_text(_DETAIL_VIEW_HELP) tools_source = index.connector_tools if is_connector else index.server_tools all_tools = sorted(tools_source.get(server_name, []), key=lambda t: t[0]) - visible_tools = [(n, c) for n, c in all_tools if n in index.enabled_tools] - if not visible_tools: + if not all_tools: option_list.add_option( - Option("No enabled tools for this server", disabled=True) + Option("No tools discovered for this server", disabled=True) ) return - for tool_name, cls in visible_tools: + for tool_name, cls in all_tools: + is_tool_enabled = tool_name in index.enabled_tools remote_name = cls.get_remote_name() raw_desc = ( (cls.description or "").removeprefix(f"[{server_name}] ").split("\n")[0] ) label = Text(no_wrap=True) - label.append(remote_name, style="bold") - if raw_desc: - label.append(f" - {raw_desc}") + if is_tool_enabled: + label.append(remote_name, style="bold") + if raw_desc: + label.append(f" - {raw_desc}") + else: + label.append(remote_name, style="dim") + if raw_desc: + label.append(f" - {raw_desc}", style="dim") + label.append(" (disabled)", style="dim italic") option_list.add_option(Option(label, id=f"tool:{tool_name}")) - if visible_tools: - option_list.highlighted = 0 + option_list.highlighted = 0 -def _tool_count_text(count: int) -> str: - if count == 0: +def _tool_count_text(enabled: int, total: int | None = None) -> str: + if total is not None and enabled < total: + noun = "tool" if total == 1 else "tools" + return f"{enabled}/{total} {noun}" + if enabled == 0: return "no tools" - noun = "tool" if count == 1 else "tools" - return f"{count} {noun}" + noun = "tool" if enabled == 1 else "tools" + return f"{enabled} {noun}" def _sort_connector_names_for_menu( diff --git a/vibe/cli/textual_ui/widgets/messages.py b/vibe/cli/textual_ui/widgets/messages.py index 0a586f2..37a05ea 100644 --- a/vibe/cli/textual_ui/widgets/messages.py +++ b/vibe/cli/textual_ui/widgets/messages.py @@ -2,6 +2,8 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, cast +from vibe.core.hooks.models import HookMessageSeverity + if TYPE_CHECKING: from vibe.cli.textual_ui.app import ChatScroll @@ -139,6 +141,9 @@ class StreamingMessageBase(Static): def _should_write_content(self) -> bool: return True + def get_content(self) -> str: + return self._content + def is_stripped_content_empty(self) -> bool: return self._content.strip() == "" @@ -298,6 +303,48 @@ class ErrorMessage(Static): pass +class HookRunContainer(Vertical): + def __init__(self) -> None: + super().__init__(classes="hook-run-container") + self.display = False + + async def add_message(self, widget: HookSystemMessageLine) -> None: + await self.mount(widget) + self.display = True + + +_HOOK_SEVERITY_ICONS: dict[HookMessageSeverity, str] = { + HookMessageSeverity.OK: "✓", + HookMessageSeverity.WARNING: "⚠", + HookMessageSeverity.ERROR: "✗", +} + + +class HookSystemMessageLine(Static): + def __init__( + self, + hook_name: str, + content: str, + severity: HookMessageSeverity = HookMessageSeverity.WARNING, + ) -> None: + super().__init__() + self.add_class("hook-system-message") + self.add_class(f"hook-severity-{severity}") + self._hook_name = hook_name + self._content = content + self._severity = severity + + def compose(self) -> ComposeResult: + icon = _HOOK_SEVERITY_ICONS.get( + self._severity, _HOOK_SEVERITY_ICONS[HookMessageSeverity.WARNING] + ) + with Horizontal(classes="hook-system-container"): + yield NonSelectableStatic(icon, classes="hook-system-icon") + yield NoMarkupStatic( + f"[{self._hook_name}] {self._content}", classes="hook-system-content" + ) + + class WarningMessage(Static): def __init__(self, message: str, show_border: bool = True) -> None: super().__init__() diff --git a/vibe/cli/textual_ui/widgets/narrator_status.py b/vibe/cli/textual_ui/widgets/narrator_status.py index 6711811..3c70963 100644 --- a/vibe/cli/textual_ui/widgets/narrator_status.py +++ b/vibe/cli/textual_ui/widgets/narrator_status.py @@ -50,12 +50,12 @@ class NarratorStatus(NarratorManagerListener, Static): case NarratorState.SUMMARIZING: char = SHRINK_FRAMES[self._frame % len(SHRINK_FRAMES)] self.update( - f"[bold orange]{char}[/bold orange] summarizing [dim]esc to stop[/dim]" + f"[bold orange]{char}[/bold orange] summarizing [dim]Esc/Ctrl+C to stop[/dim]" ) case NarratorState.SPEAKING: bars = BAR_FRAMES[self._frame % len(BAR_FRAMES)] self.update( - f"[bold orange]{bars}[/bold orange] speaking [dim]esc to stop[/dim]" + f"[bold orange]{bars}[/bold orange] speaking [dim]Esc/Ctrl+C to stop[/dim]" ) self._frame += 1 diff --git a/vibe/cli/textual_ui/widgets/path_display.py b/vibe/cli/textual_ui/widgets/path_display.py index 01c694c..85352b0 100644 --- a/vibe/cli/textual_ui/widgets/path_display.py +++ b/vibe/cli/textual_ui/widgets/path_display.py @@ -23,6 +23,9 @@ class PathDisplay(NoMarkupStatic): self.update(path_str) + def refresh_display(self) -> None: + self._update_display() + def set_path(self, path: Path | str) -> None: self._path = Path(path) self._update_display() diff --git a/vibe/cli/textual_ui/widgets/teleport_message.py b/vibe/cli/textual_ui/widgets/teleport_message.py index f6d1d4e..eb98640 100644 --- a/vibe/cli/textual_ui/widgets/teleport_message.py +++ b/vibe/cli/textual_ui/widgets/teleport_message.py @@ -15,7 +15,7 @@ class TeleportMessage(StatusMessage): if self._error: return f"Teleport failed: {self._error}" if self._final_url: - return f"Teleported to a new async coding session: {self._final_url}" + return f"Teleported to Vibe Code: {self._final_url}" return self._status def set_status(self, status: str) -> None: diff --git a/vibe/cli/textual_ui/widgets/thinking_picker.py b/vibe/cli/textual_ui/widgets/thinking_picker.py new file mode 100644 index 0000000..83d883b --- /dev/null +++ b/vibe/cli/textual_ui/widgets/thinking_picker.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from typing import Any, ClassVar, cast + +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding, BindingType +from textual.containers import Container, Vertical +from textual.message import Message +from textual.widgets import OptionList +from textual.widgets.option_list import Option + +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic +from vibe.core.config._settings import ThinkingLevel + + +def _build_option_text(level: str, is_current: bool) -> Text: + text = Text(no_wrap=True) + marker = "› " if is_current else " " + style = "bold" if is_current else "" + text.append(marker, style="green" if is_current else "") + text.append(level.capitalize(), style=style) + return text + + +class ThinkingPickerApp(Container): + can_focus_children = True + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("escape", "cancel", "Cancel", show=False) + ] + + class ThinkingSelected(Message): + level: ThinkingLevel + + def __init__(self, level: ThinkingLevel) -> None: + self.level = level + super().__init__() + + class Cancelled(Message): + pass + + def __init__( + self, thinking_levels: list[str], current_thinking: str, **kwargs: Any + ) -> None: + super().__init__(id="thinkingpicker-app", **kwargs) + self._thinking_levels = thinking_levels + self._current_thinking = current_thinking + + def compose(self) -> ComposeResult: + options = [ + Option(_build_option_text(level, level == self._current_thinking), id=level) + for level in self._thinking_levels + ] + with Vertical(id="thinkingpicker-content"): + yield NoMarkupStatic( + "Select Thinking Level", classes="thinkingpicker-title" + ) + yield OptionList(*options, id="thinkingpicker-options") + yield NoMarkupStatic( + "↑↓ Navigate Enter Select Esc Cancel", classes="thinkingpicker-help" + ) + + def on_mount(self) -> None: + option_list = self.query_one(OptionList) + for i, level in enumerate(self._thinking_levels): + if level == self._current_thinking: + option_list.highlighted = i + break + option_list.focus() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + if event.option.id: + self.post_message( + self.ThinkingSelected(cast(ThinkingLevel, event.option.id)) + ) + + def action_cancel(self) -> None: + self.post_message(self.Cancelled()) diff --git a/vibe/cli/turn_summary/port.py b/vibe/cli/turn_summary/port.py index d55280d..f3ffa0d 100644 --- a/vibe/cli/turn_summary/port.py +++ b/vibe/cli/turn_summary/port.py @@ -10,6 +10,7 @@ from vibe.core.types import BaseEvent class TurnSummaryData(BaseModel): user_message: str + message_id: str | None = None assistant_fragments: list[str] = Field(default_factory=list) error: str | None = None diff --git a/vibe/cli/turn_summary/tracker.py b/vibe/cli/turn_summary/tracker.py index 840fa52..2b5b9bf 100644 --- a/vibe/cli/turn_summary/tracker.py +++ b/vibe/cli/turn_summary/tracker.py @@ -13,7 +13,18 @@ from vibe.core.config import ModelConfig from vibe.core.llm.types import BackendLike from vibe.core.logger import logger from vibe.core.prompts import UtilityPrompt -from vibe.core.types import AssistantEvent, BaseEvent, LLMMessage, Role +from vibe.core.telemetry.build_metadata import build_request_metadata +from vibe.core.types import ( + AssistantEvent, + BaseEvent, + LLMMessage, + Role, + UserMessageEvent, +) + + +def _empty_session_metadata() -> dict[str, str]: + return {} class TurnSummaryTracker(TurnSummaryPort): @@ -23,11 +34,17 @@ class TurnSummaryTracker(TurnSummaryPort): model: ModelConfig, on_summary: Callable[[TurnSummaryResult], None] | None = None, max_tokens: int = 512, + session_metadata_getter: Callable[[], dict[str, str]] | None = None, ) -> None: self._backend = backend self._model = model self._on_summary = on_summary self._max_tokens = max_tokens + self._session_metadata_getter: Callable[[], dict[str, str]] = ( + _empty_session_metadata + if session_metadata_getter is None + else session_metadata_getter + ) self._tasks: set[asyncio.Task[Any]] = set() self._data: TurnSummaryData | None = None self._generation: int = 0 @@ -52,6 +69,8 @@ class TurnSummaryTracker(TurnSummaryPort): if self._data is None: return match event: + case UserMessageEvent(message_id=message_id): + self._data.message_id = message_id case AssistantEvent(content=c) if c: self._data.assistant_fragments.append(c) @@ -78,6 +97,15 @@ class TurnSummaryTracker(TurnSummaryPort): await asyncio.gather(*self._tasks, return_exceptions=True) self._tasks.clear() + def _build_metadata(self, data: TurnSummaryData) -> dict[str, str]: + default_metadata = build_request_metadata( + entrypoint_metadata=None, + session_id=None, + call_type="secondary_call", + message_id=data.message_id, + ).model_dump(exclude_none=True) + return default_metadata | self._session_metadata_getter() + async def _generate_summary(self, data: TurnSummaryData, gen: int) -> None: try: prompt_text = UtilityPrompt.TURN_SUMMARY.read() @@ -107,7 +135,7 @@ class TurnSummaryTracker(TurnSummaryPort): tool_choice=None, max_tokens=self._max_tokens, extra_headers={}, - metadata={}, + metadata=self._build_metadata(data), ) summary = result.message.content or "" 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 927b518..b11d388 100644 --- a/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +++ b/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py @@ -4,32 +4,61 @@ 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.paths import VIBE_HOME +_CACHE_SECTION = "update_cache" + class FileSystemUpdateCacheRepository(UpdateCacheRepository): def __init__(self, base_path: Path | str | None = None) -> None: self._base_path = Path(base_path) if base_path is not None else VIBE_HOME.path - self._cache_file = self._base_path / "update_cache.json" + self._cache_file = self._base_path / "cache.toml" + self._legacy_json = self._base_path / "update_cache.json" async def get(self) -> UpdateCache | None: - try: - content = await asyncio.to_thread(self._cache_file.read_text) - except OSError: + data = await asyncio.to_thread(self._read_section) + if data is None: return None + return self._parse(data) + + async def set(self, update_cache: UpdateCache) -> None: + payload: dict[str, str | int] = { + "latest_version": update_cache.latest_version, + "stored_at_timestamp": update_cache.stored_at_timestamp, + } + if update_cache.seen_whats_new_version is not None: + payload["seen_whats_new_version"] = update_cache.seen_whats_new_version + await asyncio.to_thread(write_cache, self._cache_file, _CACHE_SECTION, payload) + + def _read_section(self) -> dict | None: + cache = read_cache(self._cache_file) + if section := cache.get(_CACHE_SECTION): + return section try: - data = json.loads(content) - latest_version = data.get("latest_version") - stored_at_timestamp = data.get("stored_at_timestamp") - seen_whats_new_version = data.get("seen_whats_new_version") - except (TypeError, json.JSONDecodeError): + data = json.loads(self._legacy_json.read_text()) + except (OSError, json.JSONDecodeError): 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}, + ) + return data + + @staticmethod + def _parse(data: dict) -> UpdateCache | None: + latest_version = data.get("latest_version") + stored_at_timestamp = data.get("stored_at_timestamp") + seen_whats_new_version = data.get("seen_whats_new_version") + if not isinstance(latest_version, str) or not isinstance( stored_at_timestamp, int ): @@ -46,14 +75,3 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository): stored_at_timestamp=stored_at_timestamp, seen_whats_new_version=seen_whats_new_version, ) - - async def set(self, update_cache: UpdateCache) -> None: - try: - payload = json.dumps({ - "latest_version": update_cache.latest_version, - "stored_at_timestamp": update_cache.stored_at_timestamp, - "seen_whats_new_version": update_cache.seen_whats_new_version, - }) - await asyncio.to_thread(self._cache_file.write_text, payload) - except OSError: - return None diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index 13bdd50..3d4d29a 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -23,6 +23,8 @@ from vibe.cli.terminal_detect import detect_terminal from vibe.core.agents.manager import AgentManager from vibe.core.agents.models import AgentProfile, BuiltinAgentName from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig +from vibe.core.hooks.manager import HooksManager +from vibe.core.hooks.models import HookConfigResult, HookType, HookUserMessage from vibe.core.llm.backend.factory import BACKEND_FACTORY from vibe.core.llm.exceptions import BackendError from vibe.core.llm.format import ( @@ -51,11 +53,19 @@ from vibe.core.middleware import ( from vibe.core.plan_session import PlanSession from vibe.core.prompts import UtilityPrompt from vibe.core.rewind import RewindManager +from vibe.core.scratchpad import init_scratchpad +from vibe.core.session.session_id import extract_suffix, generate_session_id from vibe.core.session.session_logger import SessionLogger from vibe.core.session.session_migration import migrate_sessions_entrypoint from vibe.core.skills.manager import SkillManager from vibe.core.system_prompt import get_universal_system_prompt +from vibe.core.telemetry.build_metadata import build_request_metadata from vibe.core.telemetry.send import TelemetryClient +from vibe.core.telemetry.types import ( + EntrypointMetadata, + TelemetryCallType, + TelemetryRequestMetadata, +) from vibe.core.tools.base import ( BaseTool, InvokeContext, @@ -84,7 +94,7 @@ from vibe.core.types import ( BaseEvent, CompactEndEvent, CompactStartEvent, - EntrypointMetadata, + ContextTooLongError, LLMChunk, LLMMessage, LLMUsage, @@ -147,13 +157,21 @@ class AgentLoopLLMResponseError(AgentLoopError): class TeleportError(AgentLoopError): - """Raised when teleport to Vibe Nuage fails.""" + """Raised when teleport to Vibe Code fails.""" def _should_raise_rate_limit_error(e: Exception) -> bool: return isinstance(e, BackendError) and e.status == HTTPStatus.TOO_MANY_REQUESTS +def _is_context_too_long_error(e: Exception) -> bool: + if isinstance(e, BackendError): + return e.is_context_too_long + if isinstance(e, RuntimeError) and isinstance(e.__cause__, BackendError): + return e.__cause__.is_context_too_long + return False + + def requires_init(fn: Callable[..., Any]) -> Callable[..., Any]: """Decorator that awaits deferred initialization before executing the method.""" if inspect.isasyncgenfunction(fn): @@ -182,7 +200,7 @@ def requires_init(fn: Callable[..., Any]) -> Callable[..., Any]: class AgentLoop: - def __init__( + def __init__( # noqa: PLR0913, PLR0915 self, config: VibeConfig, *, @@ -195,13 +213,18 @@ class AgentLoop: entrypoint_metadata: EntrypointMetadata | None = None, is_subagent: bool = False, defer_heavy_init: bool = False, + headless: bool = False, + hook_config_result: HookConfigResult | None = None, ) -> None: self._base_config = config + self._headless = headless self._defer_heavy_init = defer_heavy_init self._deferred_init_thread: threading.Thread | None = None self._deferred_init_lock = threading.Lock() self._init_error: Exception | None = None + self._init_start_time = time.monotonic() + self._init_duration_ms: int | None = None self.mcp_registry = MCPRegistry() self.connector_registry = self._create_connector_registry() @@ -227,19 +250,32 @@ class AgentLoop: self.backend_factory = lambda: backend or self._select_backend() self.backend = self.backend_factory() self._sampling_handler = MCPSamplingHandler( - backend_getter=lambda: self.backend, config_getter=lambda: self.config + backend_getter=lambda: self.backend, + config_getter=lambda: self.config, + metadata_getter=lambda: self._build_backend_metadata( + call_type="secondary_call" + ).model_dump(exclude_none=True), + extra_headers_getter=self._get_extra_headers, ) self.enable_streaming = enable_streaming self.middleware_pipeline = MiddlewarePipeline() self._setup_middleware() + self.session_id = generate_session_id() + self.parent_session_id: str | None = None + self.scratchpad_dir = ( + 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) @@ -248,7 +284,6 @@ class AgentLoop: self.approval_callback: ApprovalCallback | None = None self.user_input_callback: UserInputCallback | None = None self.entrypoint_metadata = entrypoint_metadata - self.session_id = str(uuid4()) try: active_model = config.get_active_model() @@ -264,9 +299,19 @@ class AgentLoop: self._approval_lock = asyncio.Lock() self.telemetry_client = TelemetryClient( - config_getter=lambda: self.config, session_id_getter=lambda: self.session_id + config_getter=lambda: self.config, + session_id_getter=lambda: self.session_id, + parent_session_id_getter=lambda: self.parent_session_id, + entrypoint_metadata_getter=lambda: self.entrypoint_metadata, ) self.session_logger = SessionLogger(config.session_logging, self.session_id) + self._hook_config_result = hook_config_result + self._hooks_manager = ( + HooksManager(hook_config_result.hooks) if hook_config_result else None + ) + self.hook_config_issues = ( + hook_config_result.issues if hook_config_result else [] + ) self.rewind_manager = RewindManager( messages=self.messages, save_messages=self._save_messages, @@ -314,9 +359,17 @@ class AgentLoop: 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 + self.tool_manager, + self.config, + self.skill_manager, + self.agent_manager, + scratchpad_dir=self.scratchpad_dir, + headless=self._headless, ) self.messages.update_system_prompt(system_prompt) + self._init_duration_ms = int( + (time.monotonic() - self._init_start_time) * 1000 + ) except Exception as exc: self._init_error = exc @@ -328,6 +381,9 @@ class AgentLoop: await asyncio.to_thread(thread.join) if err := self._init_error: raise copy.copy(err).with_traceback(err.__traceback__) + if self._init_duration_ms is not None: + duration, self._init_duration_ms = self._init_duration_ms, None + self.emit_ready_telemetry(duration) @property def agent_profile(self) -> AgentProfile: @@ -342,8 +398,8 @@ class AgentLoop: return self.agent_manager.config @property - def auto_approve(self) -> bool: - return self.config.auto_approve + def bypass_tool_permissions(self) -> bool: + return self.config.bypass_tool_permissions def refresh_config(self) -> None: self._base_config = VibeConfig.load() @@ -434,6 +490,9 @@ class AgentLoop: terminal_emulator=terminal_emulator, ) + def emit_ready_telemetry(self, init_duration_ms: int) -> None: + self.telemetry_client.send_ready(init_duration_ms=init_duration_ms) + def _create_connector_registry(self) -> ConnectorRegistry | None: if not connectors_enabled(): return None @@ -454,13 +513,16 @@ class AgentLoop: async def refresh_system_prompt(self) -> None: """Rebuild and replace the system prompt with current tool/skill state.""" system_prompt = get_universal_system_prompt( - self.tool_manager, self.config, self.skill_manager, self.agent_manager + self.tool_manager, + self.config, + self.skill_manager, + self.agent_manager, + headless=self._headless, ) self.messages.update_system_prompt(system_prompt) def _select_backend(self) -> BackendLike: - active_model = self.config.get_active_model() - provider = self.config.get_provider_for_model(active_model) + provider = self.config.get_active_provider() timeout = self.config.api_timeout return BACKEND_FACTORY[provider.backend](provider=provider, timeout=timeout) @@ -507,16 +569,16 @@ class AgentLoop: raise TeleportError("_TeleportService is unexpectedly None") self._teleport_service = _TeleportService( session_logger=self.session_logger, - nuage_base_url=self.config.nuage_base_url, - nuage_workflow_id=self.config.nuage_workflow_id, - nuage_api_key=self.config.nuage_api_key, - nuage_task_queue=self.config.nuage_task_queue, + vibe_code_base_url=self.config.vibe_code_base_url, + vibe_code_workflow_id=self.config.vibe_code_workflow_id, + vibe_code_api_key=self.config.vibe_code_api_key, + vibe_code_task_queue=self.config.vibe_code_task_queue, vibe_config=self._base_config, ) return self._teleport_service @requires_init - async def teleport_to_vibe_nuage( + async def teleport_to_vibe_code( self, prompt: str | None ) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]: from vibe.core.teleport.errors import ServiceTeleportError @@ -600,6 +662,8 @@ class AgentLoop: threshold = result.metadata.get( "threshold", self.config.get_active_model().auto_compact_threshold ) + old_session_id = self.session_id + old_parent_session_id = self.parent_session_id tool_call_id = str(uuid4()) yield CompactStartEvent( @@ -607,14 +671,32 @@ class AgentLoop: current_context_tokens=old_tokens, threshold=threshold, ) - self.telemetry_client.send_auto_compact_triggered() - summary = await self.compact() + compact_status: Literal["success", "failure", "cancelled"] = "success" + new_tokens = self.stats.context_tokens + try: + summary = await self.compact() + except asyncio.CancelledError: + compact_status = "cancelled" + raise + except Exception: + compact_status = "failure" + raise + finally: + new_tokens = self.stats.context_tokens + self.telemetry_client.send_auto_compact_triggered( + nb_context_tokens_before=old_tokens, + nb_context_tokens_after=new_tokens, + auto_compact_threshold=threshold, + status=compact_status, + session_id=old_session_id, + parent_session_id=old_parent_session_id, + ) yield CompactEndEvent( tool_call_id=tool_call_id, old_context_tokens=old_tokens, - new_context_tokens=self.stats.context_tokens, + new_context_tokens=new_tokens, summary_length=len(summary), ) @@ -626,21 +708,25 @@ class AgentLoop: messages=self.messages, stats=self.stats, config=self.config ) - def _build_metadata(self) -> dict[str, str]: - base = self.entrypoint_metadata.model_dump() if self.entrypoint_metadata else {} - metadata = base | { - "session_id": self.session_id, - "is_user_prompt": "true" if self._is_user_prompt_call else "false", - "call_type": ( - "main_call" if self._is_user_prompt_call else "secondary_call" + def _build_backend_metadata( + self, call_type: TelemetryCallType | None = None + ) -> TelemetryRequestMetadata: + return build_request_metadata( + entrypoint_metadata=self.entrypoint_metadata, + session_id=self.session_id, + parent_session_id=self.parent_session_id, + call_type=( + call_type + if call_type is not None + else ("main_call" if self._is_user_prompt_call else "secondary_call") ), - "call_source": "vibe_code", - } - if self._current_user_message_id is not None: - metadata["message_id"] = self._current_user_message_id - return metadata + message_id=self._current_user_message_id, + ) - def _get_extra_headers(self, provider: ProviderConfig) -> dict[str, str]: + def _get_extra_headers( + self, provider: ProviderConfig | None = None + ) -> dict[str, str]: + provider = self.config.get_active_provider() if provider is None else provider headers: dict[str, str] = { "user-agent": get_user_agent(provider.backend), "x-affinity": self.session_id, @@ -662,6 +748,9 @@ class AgentLoop: yield UserMessageEvent(content=user_msg, message_id=user_message.message_id) + if self._hooks_manager: + self._hooks_manager.reset_retry_count() + try: should_break_loop = False first_llm_turn = True @@ -694,6 +783,25 @@ class AgentLoop: if user_cancelled: return + if should_break_loop and self._hooks_manager: + hook_retry: HookUserMessage | None = None + async for hook_event in self._hooks_manager.run( + HookType.POST_AGENT_TURN, self.session_id, self.session_logger + ): + if isinstance(hook_event, HookUserMessage): + hook_retry = hook_event + else: + yield hook_event + if hook_retry is not None: + self.messages.append( + LLMMessage( + role=Role.user, + content=hook_retry.content, + injected=True, + ) + ) + should_break_loop = False + finally: await self._save_messages() @@ -862,6 +970,7 @@ class AgentLoop: plan_file_path=self._plan_session.plan_file_path, switch_agent_callback=self.switch_agent, skill_manager=self.skill_manager, + scratchpad_dir=self.scratchpad_dir, ), **tool_call.args_dict, ): @@ -1007,6 +1116,7 @@ class AgentLoop: status=status, decision=decision, result=result, + message_id=self._current_user_message_id, ) def _tool_failure_event( @@ -1032,6 +1142,7 @@ class AgentLoop: ) -> LLMChunk: active_model = model_override or self.config.get_active_model() provider = self.config.get_provider_for_model(active_model) + backend_metadata = self._build_backend_metadata() available_tools = self.format_handler.get_available_tools(self.tool_manager) tool_choice = self.format_handler.get_tool_choice() @@ -1051,6 +1162,8 @@ class AgentLoop: nb_prompt_chars=len(last_user_message.content or "") if last_user_message else 0, + call_type=backend_metadata.call_type, + message_id=backend_metadata.message_id, ) try: @@ -1063,7 +1176,7 @@ class AgentLoop: tool_choice=tool_choice, extra_headers=self._get_extra_headers(provider), max_tokens=max_tokens, - metadata=self._build_metadata(), + metadata=backend_metadata.model_dump(exclude_none=True), ) end_time = time.perf_counter() @@ -1085,6 +1198,8 @@ class AgentLoop: except Exception as e: if _should_raise_rate_limit_error(e): raise RateLimitError(provider.name, active_model.name) from e + if _is_context_too_long_error(e): + raise ContextTooLongError(provider.name, active_model.name) from e raise RuntimeError( f"API error from {provider.name} (model: {active_model.name}): {e}" @@ -1094,7 +1209,8 @@ class AgentLoop: self, max_tokens: int | None = None ) -> AsyncGenerator[LLMChunk]: active_model = self.config.get_active_model() - provider = self.config.get_provider_for_model(active_model) + provider = self.config.get_active_provider() + backend_metadata = self._build_backend_metadata() available_tools = self.format_handler.get_available_tools(self.tool_manager) tool_choice = self.format_handler.get_tool_choice() @@ -1114,6 +1230,8 @@ class AgentLoop: nb_prompt_chars=len(last_user_message.content or "") if last_user_message else 0, + call_type=backend_metadata.call_type, + message_id=backend_metadata.message_id, ) try: @@ -1126,9 +1244,9 @@ class AgentLoop: temperature=active_model.temperature, tools=available_tools, tool_choice=tool_choice, - extra_headers=self._get_extra_headers(provider), + extra_headers=self._get_extra_headers(), max_tokens=max_tokens, - metadata=self._build_metadata(), + metadata=backend_metadata.model_dump(exclude_none=True), ): if chunk.correlation_id: self.telemetry_client.last_correlation_id = chunk.correlation_id @@ -1156,6 +1274,8 @@ class AgentLoop: except Exception as e: if _should_raise_rate_limit_error(e): raise RateLimitError(provider.name, active_model.name) from e + if _is_context_too_long_error(e): + raise ContextTooLongError(provider.name, active_model.name) from e raise RuntimeError( f"API error from {provider.name} (model: {active_model.name}): {e}" @@ -1174,7 +1294,7 @@ class AgentLoop: async def _should_execute_tool( self, tool: BaseTool, args: BaseModel, tool_call_id: str ) -> ToolDecision: - if self.auto_approve: + if self.bypass_tool_permissions: return ToolDecision( verdict=ToolExecutionResponse.EXECUTE, approval_type=ToolPermission.ALWAYS, @@ -1296,8 +1416,65 @@ class AgentLoop: i += 1 def _reset_session(self) -> None: - self.session_id = str(uuid4()) - self.session_logger.reset_session(self.session_id) + old_session_id = self.session_id + suffix = extract_suffix(self.session_id) + self.session_id = generate_session_id(suffix=suffix) + self.parent_session_id = old_session_id + self.session_logger.reset_session( + self.session_id, parent_session_id=old_session_id + ) + + async def fork(self, message_id: str | None = None) -> AgentLoop: + messages = self._messages_for_fork(message_id) + forked = AgentLoop( + config=self.base_config.model_copy(deep=True), + agent_name=self.agent_profile.name, + enable_streaming=self.enable_streaming, + entrypoint_metadata=self.entrypoint_metadata, + defer_heavy_init=True, + hook_config_result=self._hook_config_result, + ) + forked.session_id = generate_session_id(suffix=extract_suffix(self.session_id)) + forked.parent_session_id = self.session_id + forked.session_logger.reset_session( + forked.session_id, parent_session_id=self.session_id + ) + forked.messages.extend(messages) + await forked.session_logger.save_interaction( + forked.messages, + forked.stats, + forked.base_config, + forked.tool_manager, + forked.agent_profile, + ) + return forked + + def _messages_for_fork(self, message_id: str | None) -> list[LLMMessage]: + source_messages = [m for m in self.messages if m.role != Role.system] + if message_id is None: + return [m.model_copy(deep=True) for m in source_messages] + + anchor_index = next( + (i for i, m in enumerate(source_messages) if message_id == m.message_id), + None, + ) + if anchor_index is None: + raise ValueError(f"Cannot fork from unknown message_id: {message_id}") + + if source_messages[anchor_index].role != Role.user: + raise ValueError("Fork from message_id is only supported for user messages") + + next_turn_index = next( + ( + i + for i, m in enumerate( + source_messages[anchor_index + 1 :], start=anchor_index + 1 + ) + if m.role == Role.user + ), + len(source_messages), + ) + return [m.model_copy(deep=True) for m in source_messages[:next_turn_index]] @requires_init async def clear_history(self) -> None: @@ -1326,7 +1503,7 @@ class AgentLoop: self._reset_session() @requires_init - async def compact(self) -> str: + async def compact(self, extra_instructions: str = "") -> str: try: self._clean_message_history() await self.session_logger.save_interaction( @@ -1338,6 +1515,10 @@ class AgentLoop: ) summary_request = UtilityPrompt.COMPACT.read() + if extra_instructions: + summary_request += ( + f"\n\n## Additional Instructions\n{extra_instructions}" + ) self.stats.steps += 1 with self.messages.silent(): @@ -1359,19 +1540,17 @@ class AgentLoop: self.messages.reset([system_message, summary_message]) active_model = self.config.get_active_model() - provider = self.config.get_provider_for_model(active_model) + self._reset_session() actual_context_tokens = await self.backend.count_tokens( model=active_model, messages=self.messages, tools=self.format_handler.get_available_tools(self.tool_manager), - extra_headers={"user-agent": get_user_agent(provider.backend)}, - metadata=self._build_metadata(), + extra_headers=self._get_extra_headers(), + metadata=self._build_backend_metadata().model_dump(exclude_none=True), ) self.stats.context_tokens = actual_context_tokens - - self._reset_session() await self.session_logger.save_interaction( self.messages, self.stats, @@ -1442,7 +1621,12 @@ class AgentLoop: 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 + self.tool_manager, + self.config, + self.skill_manager, + self.agent_manager, + scratchpad_dir=self.scratchpad_dir, + headless=self._headless, ) self.messages.update_system_prompt(new_system_prompt) diff --git a/vibe/core/agents/models.py b/vibe/core/agents/models.py index 986b0cc..b7945a7 100644 --- a/vibe/core/agents/models.py +++ b/vibe/core/agents/models.py @@ -116,7 +116,7 @@ CHAT = AgentProfile( "Chat", "Read-only conversational mode for questions and discussions", AgentSafety.SAFE, - overrides={"auto_approve": True, "enabled_tools": CHAT_AGENT_TOOLS}, + overrides={"bypass_tool_permissions": True, "enabled_tools": CHAT_AGENT_TOOLS}, ) ACCEPT_EDITS = AgentProfile( BuiltinAgentName.ACCEPT_EDITS, @@ -136,7 +136,7 @@ AUTO_APPROVE = AgentProfile( "Auto Approve", "Auto-approves all tool executions", AgentSafety.YOLO, - overrides={"auto_approve": True, "base_disabled": ["exit_plan_mode"]}, + overrides={"bypass_tool_permissions": True, "base_disabled": ["exit_plan_mode"]}, ) EXPLORE = AgentProfile( diff --git a/vibe/core/autocompletion/completers.py b/vibe/core/autocompletion/completers.py index 5b90c64..73b10f1 100644 --- a/vibe/core/autocompletion/completers.py +++ b/vibe/core/autocompletion/completers.py @@ -30,6 +30,30 @@ class Completer: return None +def _prioritize_help_config_slash_menu( + items: list[tuple[str, str]], +) -> list[tuple[str, str]]: + """Place /help then /config at the head whenever they appear in the list.""" + help_item: tuple[str, str] | None = None + config_item: tuple[str, str] | None = None + rest: list[tuple[str, str]] = [] + for item in items: + match item[0]: + case "/help": + help_item = item + case "/config": + config_item = item + case _: + rest.append(item) + ordered: list[tuple[str, str]] = [] + if help_item is not None: + ordered.append(help_item) + if config_item is not None: + ordered.append(config_item) + ordered.extend(rest) + return ordered + + class CommandCompleter(Completer): def __init__(self, entries: Callable[[], list[tuple[str, str]]]) -> None: self._get_entries = entries @@ -56,11 +80,12 @@ class CommandCompleter(Completer): aliases, descriptions = self._build_lookup() word = text[1:cursor_pos].lower() search_str = "/" + word - return [ + items = [ (alias, descriptions.get(alias, "")) for alias in aliases if alias.lower().startswith(search_str) ] + return _prioritize_help_config_slash_menu(items) def get_replacement_range( self, text: str, cursor_pos: int diff --git a/vibe/core/config/__init__.py b/vibe/core/config/__init__.py index 5d1cdad..aae05b3 100644 --- a/vibe/core/config/__init__.py +++ b/vibe/core/config/__init__.py @@ -8,6 +8,8 @@ from vibe.core.config._settings import ( DEFAULT_TRANSCRIBE_PROVIDERS, DEFAULT_TTS_MODELS, DEFAULT_TTS_PROVIDERS, + THINKING_LEVELS, + ConnectorConfig, MCPHttp, MCPServer, MCPStdio, @@ -19,6 +21,7 @@ from vibe.core.config._settings import ( ProjectContextConfig, ProviderConfig, SessionLoggingConfig, + ThinkingLevel, TomlFileSettingsSource, TranscribeClient, TranscribeModelConfig, @@ -29,6 +32,15 @@ from vibe.core.config._settings import ( VibeConfig, load_dotenv_values, ) +from vibe.core.config.layer import ( + ConfigLayer, + ConfigLayerError, + EmptyLayerError, + LayerImplementationError, + RawConfig, + TrustResolutionError, + UntrustedLayerError, +) from vibe.core.config.schema import ( DuplicateMergeMetadataError, MergeFieldMetadata, @@ -47,7 +59,13 @@ __all__ = [ "DEFAULT_TRANSCRIBE_PROVIDERS", "DEFAULT_TTS_MODELS", "DEFAULT_TTS_PROVIDERS", + "THINKING_LEVELS", + "ConfigLayer", + "ConfigLayerError", + "ConnectorConfig", "DuplicateMergeMetadataError", + "EmptyLayerError", + "LayerImplementationError", "MCPHttp", "MCPServer", "MCPStdio", @@ -59,14 +77,18 @@ __all__ = [ "OtelSpanExporterConfig", "ProjectContextConfig", "ProviderConfig", + "RawConfig", "SessionLoggingConfig", "TTSClient", "TTSModelConfig", "TTSProviderConfig", + "ThinkingLevel", "TomlFileSettingsSource", "TranscribeClient", "TranscribeModelConfig", "TranscribeProviderConfig", + "TrustResolutionError", + "UntrustedLayerError", "VibeConfig", "WithConcatMerge", "WithConflictMerge", diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py index e996294..50b574b 100644 --- a/vibe/core/config/_settings.py +++ b/vibe/core/config/_settings.py @@ -7,7 +7,7 @@ from pathlib import Path import re import shlex import tomllib -from typing import Annotated, Any, Literal +from typing import Annotated, Any, Literal, get_args from urllib.parse import urljoin from dotenv import dotenv_values @@ -23,7 +23,6 @@ from pydantic_settings import ( PydanticBaseSettingsSource, SettingsConfigDict, ) -from pydantic_settings.sources.base import deep_update import tomli_w from vibe.core.config.harness_files import get_harness_files_manager @@ -35,6 +34,18 @@ from vibe.core.utils import get_server_url_from_api_base from vibe.core.utils.io import read_safe +def deep_update( + mapping: dict[str, Any], updating_mapping: dict[str, Any] +) -> dict[str, Any]: + merged = dict(mapping) + for key, value in updating_mapping.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = deep_update(merged[key], value) + else: + merged[key] = value + return merged + + def load_dotenv_values( env_path: Path = GLOBAL_ENV_FILE.path, environ: MutableMapping[str, str] = os.environ, @@ -224,6 +235,17 @@ class _MCPBase(BaseModel): default=True, description="Allow this MCP server to request LLM completions via sampling/createMessage.", ) + disabled: bool = Field( + default=False, + description="Disable all tools from this MCP server. Tools are still discovered but hidden.", + ) + disabled_tools: list[str] = Field( + default_factory=list, + description=( + "Tool names (without the server prefix) to disable from this server. " + "E.g. ['search', 'read'] to hide '{alias}_search' and '{alias}_read'." + ), + ) @field_validator("name", mode="after") @classmethod @@ -308,6 +330,23 @@ MCPServer = Annotated[ ] +class ConnectorConfig(BaseModel): + """Per-connector settings persisted in config.toml under ``[[connectors]]``.""" + + name: str = Field(description="Normalized connector alias to match against.") + disabled: bool = Field( + default=False, + description="Disable all tools from this connector. Tools are still discovered but hidden.", + ) + disabled_tools: list[str] = Field( + default_factory=list, + description=( + "Tool names (without the connector prefix) to disable. " + "E.g. ['search'] to hide 'connector_{name}_search'." + ), + ) + + def _default_alias_to_name(data: Any) -> Any: if isinstance(data, dict): if "alias" not in data or data["alias"] is None: @@ -315,6 +354,10 @@ def _default_alias_to_name(data: Any) -> Any: return data +ThinkingLevel = Literal["off", "low", "medium", "high", "max"] +THINKING_LEVELS: list[str] = list(get_args(ThinkingLevel)) + + class ModelConfig(BaseModel): name: str provider: str @@ -322,7 +365,7 @@ class ModelConfig(BaseModel): temperature: float = 0.2 input_price: float = 0.0 # Price per million input tokens output_price: float = 0.0 # Price per million output tokens - thinking: Literal["off", "low", "medium", "high", "max"] = "off" + thinking: ThinkingLevel = "off" auto_compact_threshold: int = 200_000 _default_alias_to_name = model_validator(mode="before")(_default_alias_to_name) @@ -456,7 +499,7 @@ class VibeConfig(BaseSettings): narrator_enabled: bool = False active_transcribe_model: str = "voxtral-realtime" active_tts_model: str = "voxtral-tts" - auto_approve: bool = False + bypass_tool_permissions: bool = False enable_telemetry: bool = True system_prompt_id: str = "cli" include_commit_signature: bool = True @@ -469,17 +512,19 @@ class VibeConfig(BaseSettings): api_timeout: float = 720.0 auto_compact_threshold: int = 200_000 - nuage_enabled: bool = Field(default=False, exclude=True) - nuage_base_url: str = Field(default="https://api.mistral.ai", exclude=True) - nuage_workflow_id: str = Field(default="__shared-nuage-workflow", exclude=True) - nuage_task_queue: str | None = Field(default="shared-vibe-nuage", exclude=True) - nuage_api_key_env_var: str = Field(default="MISTRAL_API_KEY", exclude=True) - nuage_project_name: str = Field(default="Vibe", exclude=True) + vibe_code_enabled: bool = Field(default=True, exclude=True) + vibe_code_base_url: str = Field(default="https://api.mistral.ai", exclude=True) + vibe_code_workflow_id: str = Field(default="__shared-nuage-workflow", exclude=True) + vibe_code_task_queue: str | None = Field(default="shared-vibe-nuage", exclude=True) + vibe_code_api_key_env_var: str = Field(default="MISTRAL_API_KEY", exclude=True) + vibe_code_project_name: str | None = Field(default=None, exclude=True) # TODO(otel): remove exclude=True once the feature is publicly available enable_otel: bool = Field(default=False, exclude=True) otel_endpoint: str = Field(default="", exclude=True) + enable_experimental_hooks: bool = Field(default=False, exclude=True) + providers: list[ProviderConfig] = Field( default_factory=lambda: list(DEFAULT_PROVIDERS) ) @@ -516,6 +561,10 @@ class VibeConfig(BaseSettings): mcp_servers: list[MCPServer] = Field( default_factory=list, description="Preferred MCP server configuration entries." ) + connectors: list[ConnectorConfig] = Field( + default_factory=list, + description="Per-connector settings (disable, disabled_tools).", + ) enabled_tools: list[str] = Field( default_factory=list, @@ -592,8 +641,8 @@ class VibeConfig(BaseSettings): return super().model_dump(**kwargs) @property - def nuage_api_key(self) -> str: - return os.getenv(self.nuage_api_key_env_var, "") + def vibe_code_api_key(self) -> str: + return os.getenv(self.vibe_code_api_key_env_var, "") @property def otel_span_exporter_config(self) -> OtelSpanExporterConfig | None: @@ -635,11 +684,6 @@ class VibeConfig(BaseSettings): @property def system_prompt(self) -> str: - try: - return SystemPrompt[self.system_prompt_id.upper()].read() - except KeyError: - pass - mgr = get_harness_files_manager() prompt_dirs = mgr.project_prompts_dirs + mgr.user_prompts_dirs for current_prompt_dir in prompt_dirs: @@ -649,6 +693,11 @@ class VibeConfig(BaseSettings): if custom_sp_path.is_file(): return read_safe(custom_sp_path).text + try: + return SystemPrompt[self.system_prompt_id.upper()].read() + except KeyError: + pass + raise MissingPromptFileError( self.system_prompt_id, *(str(d) for d in prompt_dirs) ) @@ -668,7 +717,7 @@ class VibeConfig(BaseSettings): def get_mistral_provider(self) -> ProviderConfig | None: try: - active_provider = self.get_provider_for_model(self.get_active_model()) + active_provider = self.get_active_provider() if active_provider.backend == Backend.MISTRAL: return active_provider except ValueError: @@ -683,6 +732,15 @@ class VibeConfig(BaseSettings): f"Provider '{model.provider}' for model '{model.name}' not found in configuration." ) + def get_active_provider(self) -> ProviderConfig: + return self.get_provider_for_model(self.get_active_model()) + + def is_active_model_mistral(self) -> bool: + try: + return self.get_active_provider().backend == Backend.MISTRAL + except ValueError: + return False + def get_active_transcribe_model(self) -> TranscribeModelConfig: for model in self.transcribe_models: if model.alias == self.active_transcribe_model: @@ -759,7 +817,7 @@ class VibeConfig(BaseSettings): compaction_provider = self.get_provider_for_model(self.compaction_model) try: - active_provider = self.get_provider_for_model(self.get_active_model()) + active_provider = self.get_active_provider() except ValueError: return self if active_provider.name != compaction_provider.name: @@ -773,8 +831,7 @@ class VibeConfig(BaseSettings): @model_validator(mode="after") def _check_api_key(self) -> VibeConfig: try: - active_model = self.get_active_model() - provider = self.get_provider_for_model(active_model) + provider = self.get_active_provider() api_key_env = provider.api_key_env_var if api_key_env and not os.getenv(api_key_env): raise MissingAPIKeyError(api_key_env, provider.name) @@ -849,6 +906,39 @@ class VibeConfig(BaseSettings): _ = self.system_prompt return self + def set_thinking(self, level: ThinkingLevel) -> None: + model = self.get_active_model() + + for i, m in enumerate(self.models): + if m.alias == model.alias: + self.models[i] = m.model_copy(update={"thinking": level}) + break + + current_config = TomlFileSettingsSource(type(self)).toml_data + models = current_config.get("models", []) + for entry in models: + if entry.get("alias", entry.get("name")) == model.alias: + entry["thinking"] = level + break + else: + # Model comes from defaults; materialize the full list so we + # don't lose the other models. + models = [ + { + "alias": m.alias, + "name": m.name, + "provider": m.provider, + "thinking": level if m.alias == model.alias else m.thinking, + } + for m in self.models + ] + type(self).save_updates({"models": models}) + + @classmethod + def get_persisted_config(cls) -> dict[str, Any]: + """Return the raw config as persisted in the TOML file (no profile merging).""" + return TomlFileSettingsSource(cls).toml_data + @classmethod def save_updates(cls, updates: dict[str, Any]) -> None: if not get_harness_files_manager().persist_allowed: @@ -885,10 +975,11 @@ class VibeConfig(BaseSettings): bash_tools = data.get("tools", {}).get("bash", {}) allowlist = bash_tools.get("allowlist") - if allowlist is None or "find" not in allowlist: + if allowlist is None or "find" in allowlist: return - allowlist.remove("find") + allowlist.append("find") + allowlist.sort() cls.dump_config(data) @classmethod diff --git a/vibe/core/config/harness_files/_harness_manager.py b/vibe/core/config/harness_files/_harness_manager.py index a0f9d42..454874e 100644 --- a/vibe/core/config/harness_files/_harness_manager.py +++ b/vibe/core/config/harness_files/_harness_manager.py @@ -52,6 +52,16 @@ class HarnessFilesManager: return VIBE_HOME.path / "config.toml" return None + @property + def hook_files(self) -> list[Path]: + hook_files: list[Path] = [] + if "user" in self.sources: + hook_files.append(VIBE_HOME.path / "hooks.toml") + workdir = self.trusted_workdir + if workdir is not None: + hook_files.append(workdir / ".vibe" / "hooks.toml") + return hook_files + @property def persist_allowed(self) -> bool: return "user" in self.sources diff --git a/vibe/core/config/layer.py b/vibe/core/config/layer.py new file mode 100644 index 0000000..e0d7216 --- /dev/null +++ b/vibe/core/config/layer.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +import asyncio +from dataclasses import dataclass +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class RawConfig(BaseModel): + """Permissive default schema that preserves all fields as extras.""" + + model_config = ConfigDict(extra="allow") + + +class ConfigLayerError(Exception): + """Base error for all ConfigLayer errors.""" + + def __init__(self, layer_name: str, message: str) -> None: + super().__init__(message) + self.layer_name = layer_name + + +class UntrustedLayerError(ConfigLayerError): + """Raised when attempting to load data from an untrusted layer.""" + + def __init__(self, layer_name: str) -> None: + super().__init__(layer_name, f"Layer '{layer_name}' is not trusted") + + +class EmptyLayerError(ConfigLayerError): + """Raised when a trusted layer has no data.""" + + def __init__(self, layer_name: str) -> None: + super().__init__(layer_name, f"Layer '{layer_name}' has no data after load") + + +class TrustResolutionError(ConfigLayerError): + """Raised when trust status is not resolvable.""" + + def __init__(self, layer_name: str) -> None: + super().__init__( + layer_name, + f"Layer '{layer_name}': _check_trust() must return bool, got None", + ) + + +class LayerImplementationError(ConfigLayerError): + """Raised when a subclass-provided method fails.""" + + def __init__(self, layer_name: str, method_name: str) -> None: + super().__init__(layer_name, f"Layer '{layer_name}': {method_name}() failed") + self.method_name = method_name + + +@dataclass(frozen=True, slots=True) +class _LayerState[S: BaseModel]: + is_trusted: bool | None = None + data: S | None = None + + +@dataclass(frozen=True, slots=True) +class _GrantTrust: + pass + + +@dataclass(frozen=True, slots=True) +class _RevokeTrust: + pass + + +@dataclass(frozen=True, slots=True) +class _ResolveTrust: + pass + + +@dataclass(frozen=True, slots=True) +class _Load: + force: bool = False + + +@dataclass(frozen=True, slots=True) +class _InvalidateCache: + pass + + +class ConfigLayer[S: BaseModel](ABC): + """Each layer represents a named source that produces a sparse + dictionary of configuration values. + """ + + def __init__(self, *, name: str, output_schema: type[S] = RawConfig) -> None: + self.name = name + self.output_schema = output_schema + + self._state: _LayerState[S] = _LayerState() + self._lock = asyncio.Lock() + + # --- Overridable --- + + async def _check_trust(self) -> bool: + """Resolve whether this layer should be trusted. + + Override in subclasses to implement custom trust logic. + E.g. a user-local layer returns ``True``, + a project layer checks persisted permissions. + + Returns ``False`` by default (untrusted). + """ + return False + + @abstractmethod + async def _read_config(self) -> dict[str, Any]: + """Read and return sparse dict from this layer's backing store. + + Subclasses only need to implement the raw read logic; caching + is handled by the base. + """ + ... + + async def _on_trust_changed(self, old: bool | None, new: bool | None) -> None: + """Called when the trust status changes. + + Override to persist trust status or react to trust transitions. + Default is a no-op. + """ + return + + # --- Internal --- + + async def _notify_trust_change(self, old: bool | None, new: bool | None) -> None: + """Call ``_on_trust_changed`` and wrap any error.""" + if old == new: + return + try: + await self._on_trust_changed(old, new) + except Exception as e: + raise LayerImplementationError(self.name, "_on_trust_changed") from e + + async def _resolve_check_trust(self) -> bool: + """Call ``_check_trust`` and wrap any error.""" + try: + return await self._check_trust() + except Exception as e: + raise LayerImplementationError(self.name, "_check_trust") from e + + async def _dispatch(self, action: Any) -> _LayerState[S]: + """Serialize all state mutations through a single lock.""" + async with self._lock: + state = _LayerState( + is_trusted=self._state.is_trusted, data=self._state.data + ) + + match action: + case _GrantTrust(): + new_state = await self._handle_grant_trust(state) + case _RevokeTrust(): + new_state = await self._handle_revoke_trust(state) + case _ResolveTrust(): + new_state = await self._handle_resolve_trust(state) + case _Load(force=force): + new_state = await self._handle_load(state, force) + case _InvalidateCache(): + new_state = await self._handle_invalidate_cache(state) + case _: + raise NotImplementedError(f"Unknown action: {action!r}") + + self._state = new_state + + return new_state + + async def _handle_grant_trust(self, state: _LayerState[S]) -> _LayerState[S]: + if state.is_trusted is True: + return state + + await self._notify_trust_change(state.is_trusted, True) + + return _LayerState(is_trusted=True, data=state.data) + + async def _handle_revoke_trust(self, state: _LayerState[S]) -> _LayerState[S]: + if state.is_trusted is False: + return state + + await self._notify_trust_change(state.is_trusted, False) + + return _LayerState(is_trusted=False, data=None) + + async def _handle_resolve_trust(self, state: _LayerState[S]) -> _LayerState[S]: + is_trusted = await self._resolve_check_trust() + + await self._notify_trust_change(state.is_trusted, is_trusted) + + return _LayerState( + is_trusted=is_trusted, data=state.data if is_trusted else None + ) + + async def _handle_load(self, state: _LayerState[S], force: bool) -> _LayerState[S]: + if state.is_trusted is not None: + is_trusted = state.is_trusted + else: + is_trusted = await self._resolve_check_trust() + + await self._notify_trust_change(state.is_trusted, is_trusted) + + if not is_trusted: + return _LayerState(is_trusted=is_trusted, data=None) + + next_state = _LayerState(is_trusted=is_trusted, data=state.data) + + if next_state.data is None or force: + try: + raw = await self._read_config() + except Exception as e: + raise LayerImplementationError(self.name, "_read_config") from e + next_state = _LayerState( + is_trusted=next_state.is_trusted, data=self.validate_output(raw) + ) + + return next_state + + async def _handle_invalidate_cache(self, state: _LayerState[S]) -> _LayerState[S]: + return _LayerState(is_trusted=state.is_trusted, data=None) + + # --- Public --- + + @property + def is_trusted(self) -> bool | None: + """Current trust status. ``None`` if unresolved.""" + return self._state.is_trusted + + async def resolve_trust(self) -> bool: + """Resolve and cache trust status.""" + state = await self._dispatch(_ResolveTrust()) + + if state.is_trusted is None: + raise TrustResolutionError(self.name) + + return state.is_trusted + + async def grant_trust(self) -> None: + """Explicitly mark this layer as trusted.""" + await self._dispatch(_GrantTrust()) + + async def revoke_trust(self) -> None: + """Explicitly mark this layer as untrusted and clear cached data.""" + await self._dispatch(_RevokeTrust()) + + async def invalidate_cache(self) -> None: + """Clear the in-memory cache so the next ``load()`` re-reads.""" + await self._dispatch(_InvalidateCache()) + + async def load(self, *, force: bool = False) -> S: + """Load data from this layer, enforcing trust and caching the result. + + Use ``force=True`` to bypass caching. + """ + state = await self._dispatch(_Load(force=force)) + + if state.is_trusted is None: + raise TrustResolutionError(self.name) + + if not state.is_trusted: + raise UntrustedLayerError(self.name) + + if state.data is None: + raise EmptyLayerError(self.name) + + return state.data.model_copy(deep=True) + + async def get_fingerprint(self) -> str: + """Return opaque token representing current backing store state.""" + raise NotImplementedError + + async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None: + """Persist a patch to this layer's backing store.""" + raise NotImplementedError + + def validate_output(self, data: dict[str, Any]) -> S: + """Validate *data* against ``output_schema``.""" + return self.output_schema.model_validate(data) + + def __repr__(self) -> str: + return f"{type(self).__name__}(name={self.name!r})" diff --git a/vibe/core/hooks/__init__.py b/vibe/core/hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vibe/core/hooks/config.py b/vibe/core/hooks/config.py new file mode 100644 index 0000000..1a9d11d --- /dev/null +++ b/vibe/core/hooks/config.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from pathlib import Path +import tomllib +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from vibe.core.config import VibeConfig +from vibe.core.config.harness_files import get_harness_files_manager +from vibe.core.hooks.models import HookConfig, HookConfigIssue, HookConfigResult +from vibe.core.utils.io import read_safe + + +class _HooksTomlRoot(BaseModel): + model_config = ConfigDict(extra="ignore") + hooks: list[Any] = Field(default_factory=list) + + +def _format_validation_error( + err: ValidationError, *, root_label: str = "config" +) -> str: + """Single-line, human-readable summary (avoids pydantic's multi-line str(err) dump).""" + parts: list[str] = [] + for item in err.errors(): + loc = item.get("loc") or () + loc_s = ".".join(str(x) for x in loc) if loc else root_label + parts.append(f"{loc_s}: {item.get('msg', 'invalid')}") + return " ; ".join(parts) + + +def _hook_entry_label(entry: Any, index: int) -> str: + if isinstance(entry, dict) and (name := entry.get("name")): + return str(name) + return f"hooks[{index}]" + + +def _load_hooks_file(path: Path) -> HookConfigResult: + hooks: list[HookConfig] = [] + issues: list[HookConfigIssue] = [] + + if not path.is_file(): + return HookConfigResult(hooks=hooks, issues=issues) + + try: + text = read_safe(path).text + data = tomllib.loads(text) + except (OSError, tomllib.TOMLDecodeError) as e: + issues.append(HookConfigIssue(file=path, message=f"Failed to parse: {e}")) + return HookConfigResult(hooks=hooks, issues=issues) + + try: + root = _HooksTomlRoot.model_validate(data) + except ValidationError as e: + issues.append( + HookConfigIssue( + file=path, message=_format_validation_error(e, root_label="hooks file") + ) + ) + return HookConfigResult(hooks=hooks, issues=issues) + + for i, entry in enumerate(root.hooks): + try: + hooks.append(HookConfig.model_validate(entry)) + except ValidationError as e: + label = _hook_entry_label(entry, i) + issues.append( + HookConfigIssue( + file=path, + message=f"{label} - {_format_validation_error(e, root_label='hook')}", + ) + ) + + return HookConfigResult(hooks=hooks, issues=issues) + + +def load_hooks_from_fs(config: VibeConfig) -> HookConfigResult: + if not config.enable_experimental_hooks: + return HookConfigResult(hooks=[], issues=[]) + + all_hooks: list[HookConfig] = [] + all_issues: list[HookConfigIssue] = [] + seen_names: set[str] = set() + mgr = get_harness_files_manager() + + for path in mgr.hook_files: + result = _load_hooks_file(path) + all_issues.extend(result.issues) + for hook in result.hooks: + if hook.name in seen_names: + all_issues.append( + HookConfigIssue( + file=path, message=f"Duplicate hook name: {hook.name!r}" + ) + ) + continue + seen_names.add(hook.name) + all_hooks.append(hook) + + return HookConfigResult(hooks=all_hooks, issues=all_issues) diff --git a/vibe/core/hooks/executor.py b/vibe/core/hooks/executor.py new file mode 100644 index 0000000..92fc938 --- /dev/null +++ b/vibe/core/hooks/executor.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import asyncio + +from vibe.core.hooks.config import HookConfig +from vibe.core.hooks.models import HookExecutionResult, HookInvocation +from vibe.core.utils import kill_async_subprocess +from vibe.core.utils.io import decode_safe + + +class HookExecutor: + async def run( + self, hook: HookConfig, invocation: HookInvocation + ) -> HookExecutionResult: + stdin_data = invocation.model_dump_json().encode() + + try: + process = await asyncio.create_subprocess_shell( + hook.command, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + start_new_session=True, + ) + except OSError as e: + return HookExecutionResult( + hook_name=hook.name, + exit_code=1, + stdout=f"Failed to start: {e}", + stderr="", + timed_out=False, + ) + + try: + stdout_bytes, stderr_bytes = await asyncio.wait_for( + process.communicate(input=stdin_data), timeout=hook.timeout + ) + stdout = decode_safe(stdout_bytes).text.strip() + stderr = decode_safe(stderr_bytes).text.strip() + return HookExecutionResult( + hook_name=hook.name, + exit_code=process.returncode, + stdout=stdout, + stderr=stderr, + timed_out=False, + ) + except TimeoutError: + await kill_async_subprocess(process) + return HookExecutionResult( + hook_name=hook.name, + exit_code=None, + stdout="", + stderr="", + timed_out=True, + ) diff --git a/vibe/core/hooks/manager.py b/vibe/core/hooks/manager.py new file mode 100644 index 0000000..c71f801 --- /dev/null +++ b/vibe/core/hooks/manager.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from collections.abc import AsyncGenerator +from enum import IntEnum +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +from vibe.core.hooks.config import HookConfig +from vibe.core.hooks.executor import HookExecutor +from vibe.core.hooks.models import ( + HookEndEvent, + HookInvocation, + HookMessageSeverity, + HookRunEndEvent, + HookRunStartEvent, + HookStartEvent, + HookType, + HookUserMessage, +) +from vibe.core.types import BaseEvent + +if TYPE_CHECKING: + from vibe.core.session.session_logger import SessionLogger + +logger = logging.getLogger(__name__) + +_MAX_RETRIES = 3 + + +class HookExitCode(IntEnum): + SUCCESS = 0 + RETRY = 2 + + +class HookRetryState: + def __init__(self) -> None: + self._counts: dict[str, int] = {} + + def reset(self) -> None: + self._counts.clear() + + def remaining_retries(self, hook_name: str) -> int: + return _MAX_RETRIES - self._counts.get(hook_name, 0) + + def track_retry(self, hook_name: str) -> None: + self._counts[hook_name] = self._counts.get(hook_name, 0) + 1 + + def track_success(self, hook_name: str) -> None: + self._counts.pop(hook_name, None) + + def should_retry(self, hook_name: str) -> bool: + return self._counts.get(hook_name, 0) < _MAX_RETRIES + + +class HooksManager: + def __init__(self, hooks: list[HookConfig]) -> None: + self._hooks_by_type: dict[HookType, list[HookConfig]] = {} + for hook in hooks: + self._hooks_by_type.setdefault(hook.type, []).append(hook) + self._executor = HookExecutor() + self._retry_state = HookRetryState() + + def has_hooks(self, hook_type: HookType) -> bool: + return bool(self._hooks_by_type.get(hook_type)) + + def reset_retry_count(self) -> None: + self._retry_state.reset() + + async def run( + self, hook_type: HookType, session_id: str, session_logger: SessionLogger + ) -> AsyncGenerator[BaseEvent | HookUserMessage]: + hooks = self._hooks_by_type.get(hook_type, []) + if not hooks: + return + invocation = _build_invocation(hook_type, session_id, session_logger) + + yield HookRunStartEvent() + for hook in hooks: + yield HookStartEvent(hook_name=hook.name) + result = await self._executor.run(hook, invocation) + + if result.timed_out or result.exit_code is None: + yield HookEndEvent( + hook_name=hook.name, + status=HookMessageSeverity.WARNING, + content=f"Timed out after {hook.timeout}s", + ) + elif result.exit_code == HookExitCode.SUCCESS: + yield HookEndEvent(hook_name=hook.name, status=HookMessageSeverity.OK) + elif result.exit_code == HookExitCode.RETRY and result.stdout: + logger.debug("Hook %s retry output: %s", hook.name, result.stdout) + + if not self._retry_state.should_retry(hook.name): + yield HookEndEvent( + hook_name=hook.name, + status=HookMessageSeverity.ERROR, + content=f"Failed, retries exhausted ({_MAX_RETRIES}/{_MAX_RETRIES})", + ) + continue + + remaining = self._retry_state.remaining_retries(hook.name) + self._retry_state.track_retry(hook.name) + yield HookEndEvent( + hook_name=hook.name, + status=HookMessageSeverity.ERROR, + content=f"Failed, retrying ({remaining} {'retry' if remaining == 1 else 'retries'} remaining)", + ) + yield HookUserMessage(content=result.stdout) + break + else: + yield HookEndEvent( + hook_name=hook.name, + status=HookMessageSeverity.WARNING, + content=( + result.stdout + or result.stderr + or f"Exited with code {result.exit_code}" + ), + ) + + if result.exit_code != HookExitCode.RETRY: + self._retry_state.track_success(hook.name) + + yield HookRunEndEvent() + + +def _build_invocation( + hook_type: HookType, session_id: str, session_logger: SessionLogger +) -> HookInvocation: + transcript_path = "" + if session_logger.enabled and session_logger.session_dir is not None: + transcript_path = str(session_logger.messages_filepath.resolve()) + + return HookInvocation( + session_id=session_id, + transcript_path=transcript_path, + cwd=str(Path.cwd().resolve()), + hook_event_name=hook_type.value, + ) diff --git a/vibe/core/hooks/models.py b/vibe/core/hooks/models.py new file mode 100644 index 0000000..0cf5cd7 --- /dev/null +++ b/vibe/core/hooks/models.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from enum import auto +from pathlib import Path + +from pydantic import BaseModel, field_validator + +from vibe.core.types import BaseEvent, StrEnum + +# --- Types & enums --- + + +class HookMessageSeverity(StrEnum): + OK = auto() + WARNING = auto() + ERROR = auto() + + +class HookType(StrEnum): + POST_AGENT_TURN = auto() + + +# --- Declarative hook config (TOML on disk) --- + + +class HookConfig(BaseModel): + name: str + type: HookType + command: str + timeout: float = 30.0 + description: str | None = None + + @field_validator("command") + @classmethod + def command_not_blank(cls, v: str) -> str: + if not v.strip(): + raise ValueError("command must not be empty") + return v + + +class HookConfigIssue(BaseModel): + file: Path + message: str + + +class HookConfigResult(BaseModel): + hooks: list[HookConfig] + issues: list[HookConfigIssue] + + +# --- Subprocess execution --- + + +class HookInvocation(BaseModel): + session_id: str + transcript_path: str + cwd: str + hook_event_name: str + + +class HookExecutionResult(BaseModel): + hook_name: str + exit_code: int | None + stdout: str + stderr: str + timed_out: bool + + +# --- Injected user message (retry / hook stdout) --- + + +class HookUserMessage(BaseModel): + content: str + + +# --- Transcript / UI events (BaseEvent) --- + + +class HookEvent(BaseEvent): + pass + + +class HookRunStartEvent(HookEvent): + pass + + +class HookRunEndEvent(HookEvent): + pass + + +class HookStartEvent(HookEvent): + hook_name: str + + +class HookEndEvent(HookEvent): + hook_name: str + status: HookMessageSeverity + content: str | None = None diff --git a/vibe/core/llm/backend/anthropic.py b/vibe/core/llm/backend/anthropic.py index 9e7fb4f..581f44a 100644 --- a/vibe/core/llm/backend/anthropic.py +++ b/vibe/core/llm/backend/anthropic.py @@ -375,9 +375,26 @@ class AnthropicAdapter(APIAdapter): if last_block.get("type") in {"text", "image", "tool_result"}: last_block["cache_control"] = {"type": "ephemeral"} - @staticmethod - def _is_adaptive_model(model_name: str) -> bool: - return "opus-4-6" in model_name + # Anthropic models that require the `thinking={"type":"adaptive"}` + + # `output_config.effort` shape and reject the older + # `thinking={"type":"enabled","budget_tokens":...}` shape. Add new + # adaptive-only model families here as Anthropic ships them. + ADAPTIVE_MODEL_TAGS: ClassVar[frozenset[str]] = frozenset({"opus-4-6", "opus-4-7"}) + + # Anthropic models that have deprecated the `temperature` parameter and + # reject any payload containing it. Add new families here as Anthropic + # ships them. + TEMPERATURE_DEPRECATED_MODEL_TAGS: ClassVar[frozenset[str]] = frozenset({ + "opus-4-7" + }) + + @classmethod + def _is_adaptive_model(cls, model_name: str) -> bool: + return any(tag in model_name for tag in cls.ADAPTIVE_MODEL_TAGS) + + @classmethod + def _is_temperature_deprecated_model(cls, model_name: str) -> bool: + return any(tag in model_name for tag in cls.TEMPERATURE_DEPRECATED_MODEL_TAGS) def _apply_thinking_config( self, @@ -391,9 +408,11 @@ class AnthropicAdapter(APIAdapter): ) -> None: has_thinking = self._has_thinking_content(messages) thinking_level = thinking + temperature_deprecated = self._is_temperature_deprecated_model(model_name) if thinking_level == "off" and not has_thinking: - payload["temperature"] = temperature + if not temperature_deprecated: + payload["temperature"] = temperature if max_tokens is not None: payload["max_tokens"] = max_tokens else: @@ -405,7 +424,7 @@ class AnthropicAdapter(APIAdapter): effective_level = thinking_level if thinking_level != "off" else "medium" if self._is_adaptive_model(model_name): - payload["thinking"] = {"type": "adaptive"} + payload["thinking"] = {"type": "adaptive", "display": "summarized"} payload["output_config"] = {"effort": effective_level} default_max = self.DEFAULT_ADAPTIVE_MAX_TOKENS else: @@ -413,7 +432,8 @@ class AnthropicAdapter(APIAdapter): payload["thinking"] = {"type": "enabled", "budget_tokens": budget} default_max = budget + self.DEFAULT_MAX_TOKENS - payload["temperature"] = 1 + if not temperature_deprecated: + payload["temperature"] = 1 payload["max_tokens"] = max_tokens if max_tokens is not None else default_max def _build_payload( diff --git a/vibe/core/llm/backend/generic.py b/vibe/core/llm/backend/generic.py index 162b0c4..4c920db 100644 --- a/vibe/core/llm/backend/generic.py +++ b/vibe/core/llm/backend/generic.py @@ -10,6 +10,7 @@ import httpx from vibe.core.llm.backend.anthropic import AnthropicAdapter from vibe.core.llm.backend.base import APIAdapter, PreparedRequest +from vibe.core.llm.backend.openai_responses import OpenAIResponsesAdapter from vibe.core.llm.backend.reasoning_adapter import ReasoningAdapter from vibe.core.llm.exceptions import BackendErrorBuilder from vibe.core.llm.message_utils import merge_consecutive_user_messages @@ -98,7 +99,12 @@ class OpenAIAdapter(APIAdapter): self._reasoning_to_api( msg.model_dump( exclude_none=True, - exclude={"message_id", "reasoning_message_id", "injected"}, + exclude={ + "message_id", + "reasoning_message_id", + "reasoning_state", + "injected", + }, ), field_name, ) @@ -167,9 +173,9 @@ _ADAPTERS: dict[str, APIAdapter] = { def _get_adapter(api_style: str) -> APIAdapter: - """Loads the appropriate adapter for the given API style, - lazily if the adapter is not already loaded. - """ + """Load the adapter for the given API style.""" + if api_style == "openai-responses": + return OpenAIResponsesAdapter() if api_style not in _ADAPTERS: if api_style == "vertex-anthropic": from vibe.core.llm.backend.vertex import VertexAnthropicAdapter diff --git a/vibe/core/llm/backend/openai_responses.py b/vibe/core/llm/backend/openai_responses.py new file mode 100644 index 0000000..d0bfa83 --- /dev/null +++ b/vibe/core/llm/backend/openai_responses.py @@ -0,0 +1,641 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +import json +import logging +from typing import TYPE_CHECKING, Any, ClassVar, TypedDict, cast + +from pydantic import TypeAdapter + +from vibe.core.llm.backend.base import APIAdapter, PreparedRequest +from vibe.core.llm.message_utils import merge_consecutive_user_messages +from vibe.core.types import ( + AvailableTool, + FunctionCall, + LLMChunk, + LLMMessage, + LLMUsage, + Role, + StrToolChoice, + ToolCall, +) + +if TYPE_CHECKING: + from vibe.core.config import ProviderConfig + +logger = logging.getLogger(__name__) + +_EMPTY_USAGE = LLMUsage(prompt_tokens=0, completion_tokens=0) + + +class _ResponsesUsageData(TypedDict, total=False): + input_tokens: int + output_tokens: int + + +class _ResponsesFunctionCallItem(TypedDict, total=False): + type: str + id: str + call_id: str + name: str + arguments: str + + +class _ResponsesContentBlock(TypedDict, total=False): + type: str + text: str + + +class _ResponsesSummaryBlock(TypedDict, total=False): + type: str + text: str + + +class _ResponsesMessageItem(TypedDict, total=False): + type: str + id: str + role: str + phase: str + content: list[_ResponsesContentBlock] + + +class _ResponsesReasoningItem(TypedDict, total=False): + type: str + encrypted_content: str + summary: list[_ResponsesSummaryBlock] + + +class _ResponsesObject(TypedDict, total=False): + usage: _ResponsesUsageData | None + output: list[dict[str, Any]] + + +class _ResponsesErrorData(TypedDict, total=False): + type: str + message: str + + +class _ResponsesStreamEvent(TypedDict, total=False): + type: str + output_index: int + delta: str + call_id: str + name: str + arguments: str + item: dict[str, Any] + response: _ResponsesObject + error: _ResponsesErrorData + + +_RESPONSES_OBJECT_ADAPTER = TypeAdapter(_ResponsesObject) +_RESPONSES_STREAM_EVENT_ADAPTER = TypeAdapter(_ResponsesStreamEvent) +_RESPONSES_FUNCTION_CALL_ITEM_ADAPTER = TypeAdapter(_ResponsesFunctionCallItem) +_RESPONSES_MESSAGE_ITEM_ADAPTER = TypeAdapter(_ResponsesMessageItem) +_RESPONSES_REASONING_ITEM_ADAPTER = TypeAdapter(_ResponsesReasoningItem) +_RESPONSES_ERROR_DATA_ADAPTER = TypeAdapter(_ResponsesErrorData) + + +@dataclass(slots=True) +class _ResponsesToolCallState: + call_id: str | None = None + name: str | None = None + arguments: str = "" + name_emitted: bool = False + arguments_emitted: bool = False + + +class _OpenAIResponsesStreamParser: + def __init__(self) -> None: + self._commentary_indices: set[int] = set() + self._ignored_event_types: set[str] = set() + self._tool_call_states: dict[int, _ResponsesToolCallState] = {} + + def reset(self) -> None: + self._commentary_indices.clear() + self._ignored_event_types.clear() + self._tool_call_states.clear() + + def parse(self, data: _ResponsesStreamEvent) -> LLMChunk: + handler = self._EVENT_HANDLERS.get(data.get("type", "")) + if handler is not None: + return handler(self, data) + return self._on_unknown_event(data) + + @staticmethod + def _is_commentary_message(item: dict[str, Any]) -> bool: + return item.get("type") == "message" and item.get("phase") == "commentary" + + @staticmethod + def _usage_from_response(usage_data: _ResponsesUsageData | None) -> LLMUsage: + usage = usage_data or {} + return LLMUsage( + prompt_tokens=usage.get("input_tokens", 0), + completion_tokens=usage.get("output_tokens", 0), + ) + + @staticmethod + def _reasoning_state_from_output(output: list[dict[str, Any]]) -> list[str] | None: + reasoning_state: list[str] = [] + for item in output: + if item.get("type") != "reasoning": + continue + reasoning_item = _RESPONSES_REASONING_ITEM_ADAPTER.validate_python(item) + encrypted_content = reasoning_item.get("encrypted_content") + if encrypted_content: + reasoning_state.append(encrypted_content) + return reasoning_state or None + + @staticmethod + def _tool_call_from_item( + item: _ResponsesFunctionCallItem, *, index: int | None = None + ) -> ToolCall: + item = _RESPONSES_FUNCTION_CALL_ITEM_ADAPTER.validate_python(item) + return ToolCall( + id=item.get("call_id") or item.get("id"), + index=index, + function=FunctionCall( + name=item.get("name"), arguments=item.get("arguments", "") + ), + ) + + @staticmethod + def _empty_chunk() -> LLMChunk: + return LLMChunk( + message=LLMMessage(role=Role.assistant, content=""), usage=_EMPTY_USAGE + ) + + @staticmethod + def _assistant_text_chunk(text: str) -> LLMChunk: + return LLMChunk( + message=LLMMessage(role=Role.assistant, content=text), usage=_EMPTY_USAGE + ) + + @staticmethod + def _tool_call_chunk( + call_id: str | None, name: str | None, arguments: str, index: int | None + ) -> LLMChunk: + if index is None: + raise ValueError("Tool call chunk missing index") + return LLMChunk( + message=LLMMessage( + role=Role.assistant, + content="", + tool_calls=[ + ToolCall( + id=call_id, + index=index, + function=FunctionCall(name=name, arguments=arguments), + ) + ], + ), + usage=_EMPTY_USAGE, + ) + + @staticmethod + def _reasoning_chunk(reasoning_content: str) -> LLMChunk: + return LLMChunk( + message=LLMMessage( + role=Role.assistant, content="", reasoning_content=reasoning_content + ), + usage=_EMPTY_USAGE, + ) + + def _remember_tool_call_state( + self, + *, + index: int, + call_id: str | None, + name: str | None, + arguments: str | None, + name_emitted: bool | None = None, + arguments_emitted: bool | None = None, + ) -> None: + state = self._tool_call_states.setdefault(index, _ResponsesToolCallState()) + if call_id: + state.call_id = call_id + if name: + state.name = name + if arguments is not None: + state.arguments = arguments + if name_emitted is not None: + state.name_emitted = name_emitted + if arguments_emitted is not None: + state.arguments_emitted = arguments_emitted + + def _finalize_tool_call( + self, + *, + index: int | None, + call_id: str | None, + name: str | None, + arguments: str | None, + ) -> LLMChunk: + if index is None: + raise ValueError("Tool call chunk missing index") + + state = self._tool_call_states.get(index, _ResponsesToolCallState()) + resolved_call_id = call_id or state.call_id + resolved_name = name or state.name + previous_arguments = state.arguments + final_arguments = arguments if arguments is not None else previous_arguments + if ( + previous_arguments + and final_arguments + and not final_arguments.startswith(previous_arguments) + ): + logger.warning( + "OpenAI Responses tool call arguments mismatch; using full final arguments from done event. previous=%r current=%r", + previous_arguments, + final_arguments, + ) + + should_emit_name = bool(resolved_name and not state.name_emitted) + should_emit_arguments = bool(final_arguments) and not state.arguments_emitted + + self._remember_tool_call_state( + index=index, + call_id=resolved_call_id, + name=resolved_name, + arguments=final_arguments, + name_emitted=state.name_emitted or should_emit_name, + arguments_emitted=state.arguments_emitted or should_emit_arguments, + ) + + if not should_emit_name and not should_emit_arguments: + return self._empty_chunk() + + return self._tool_call_chunk( + call_id=resolved_call_id, + name=resolved_name, + arguments=final_arguments if should_emit_arguments else "", + index=index, + ) + + def _on_response_created(self, _data: _ResponsesStreamEvent) -> LLMChunk: + self.reset() + return self._empty_chunk() + + def _on_text_delta(self, data: _ResponsesStreamEvent) -> LLMChunk: + delta = data.get("delta", "") + if data.get("output_index", 0) not in self._commentary_indices: + return self._assistant_text_chunk(delta) + return self._reasoning_chunk(delta) + + def _on_reasoning_delta(self, data: _ResponsesStreamEvent) -> LLMChunk: + return self._reasoning_chunk(data.get("delta", "")) + + def _on_tool_call_delta(self, data: _ResponsesStreamEvent) -> LLMChunk: + delta = data.get("delta", "") + if not delta and not data.get("name") and not data.get("call_id"): + return self._empty_chunk() + + index = data.get("output_index") + if index is None: + raise ValueError("Tool call chunk missing index") + + state = self._tool_call_states.get(index, _ResponsesToolCallState()) + self._remember_tool_call_state( + index=index, + call_id=data.get("call_id"), + name=data.get("name"), + arguments=state.arguments + delta, + name_emitted=state.name_emitted, + arguments_emitted=state.arguments_emitted, + ) + return self._empty_chunk() + + def _on_output_item_added(self, data: _ResponsesStreamEvent) -> LLMChunk: + item = data.get("item") or {} + match item.get("type"): + case "message" if self._is_commentary_message(item): + self._commentary_indices.add(data.get("output_index", 0)) + case "function_call": + item = _RESPONSES_FUNCTION_CALL_ITEM_ADAPTER.validate_python(item) + index = data.get("output_index") + if index is not None: + self._remember_tool_call_state( + index=index, + call_id=item.get("call_id") or item.get("id"), + name=item.get("name"), + arguments=item.get("arguments", ""), + name_emitted=bool(item.get("name")), + arguments_emitted=False, + ) + tool_call = self._tool_call_from_item( + cast(_ResponsesFunctionCallItem, item), index=index + ) + return self._tool_call_chunk( + call_id=tool_call.id, + name=tool_call.function.name, + arguments="", + index=tool_call.index, + ) + return self._empty_chunk() + + def _on_tool_call_done(self, data: _ResponsesStreamEvent) -> LLMChunk: + return self._finalize_tool_call( + index=data.get("output_index"), + call_id=data.get("call_id"), + name=data.get("name"), + arguments=data.get("arguments"), + ) + + def _on_output_item_done(self, data: _ResponsesStreamEvent) -> LLMChunk: + item = data.get("item") or {} + match item.get("type"): + case "message" if self._is_commentary_message(item): + self._commentary_indices.add(data.get("output_index", 0)) + case "function_call": + item = _RESPONSES_FUNCTION_CALL_ITEM_ADAPTER.validate_python(item) + return self._finalize_tool_call( + index=data.get("output_index"), + call_id=item.get("call_id") or item.get("id"), + name=item.get("name"), + arguments=item.get("arguments"), + ) + return self._empty_chunk() + + def _on_response_terminal(self, data: _ResponsesStreamEvent) -> LLMChunk: + response_obj = cast(_ResponsesObject, data.get("response") or {}) + self.reset() + output = response_obj.get("output") or [] + return LLMChunk( + message=LLMMessage( + role=Role.assistant, + content="", + reasoning_state=self._reasoning_state_from_output(output), + ), + usage=self._usage_from_response(response_obj.get("usage")), + ) + + def _on_error(self, data: _ResponsesStreamEvent) -> LLMChunk: + self.reset() + error = _RESPONSES_ERROR_DATA_ADAPTER.validate_python(data.get("error") or {}) + error_type = error.get("type", "unknown_error") + error_message = error.get("message", "Unknown streaming error") + raise RuntimeError( + f"OpenAI Responses stream error ({error_type}): {error_message}" + ) + + def _on_unknown_event(self, data: _ResponsesStreamEvent) -> LLMChunk: + if event_type := data.get("type"): + if event_type not in self._ignored_event_types: + logger.debug( + "Ignoring OpenAI Responses stream event type: %s", event_type + ) + self._ignored_event_types.add(event_type) + return self._empty_chunk() + + _EVENT_HANDLERS: ClassVar[ + dict[ + str, + Callable[[_OpenAIResponsesStreamParser, _ResponsesStreamEvent], LLMChunk], + ] + ] = { + "response.created": _on_response_created, + "response.output_text.delta": _on_text_delta, + "response.reasoning_summary_text.delta": _on_reasoning_delta, + "response.summary_text.delta": _on_reasoning_delta, + "response.function_call_arguments.delta": _on_tool_call_delta, + "response.function_call_arguments.done": _on_tool_call_done, + "response.output_item.added": _on_output_item_added, + "response.output_item.done": _on_output_item_done, + "response.completed": _on_response_terminal, + "response.incomplete": _on_response_terminal, + "error": _on_error, + } + + +class OpenAIResponsesAdapter(APIAdapter): + endpoint: ClassVar[str] = "/responses" + + def __init__(self) -> None: + self._stream_parser = _OpenAIResponsesStreamParser() + + @staticmethod + def _is_temperature_supported(model_name: str) -> bool: + supported_prefixes = ("gpt-4", "gpt-3.5") + return model_name.startswith(supported_prefixes) + + @staticmethod + def _map_reasoning_effort(thinking: str) -> str: + if thinking == "off": + return "none" + if thinking == "max": + return "xhigh" + return thinking + + def _convert_messages(self, messages: Sequence[LLMMessage]) -> list[dict[str, Any]]: + input_items: list[dict[str, Any]] = [] + + for msg in messages: + match msg.role: + case Role.system: + input_items.append({"role": "system", "content": msg.content or ""}) + + case Role.user: + input_items.append({"role": "user", "content": msg.content or ""}) + + case Role.assistant: + for encrypted_content in msg.reasoning_state or []: + input_items.append({ + "type": "reasoning", + "encrypted_content": encrypted_content, + }) + input_items.append({ + "role": "assistant", + "content": [{"type": "output_text", "text": msg.content or ""}], + }) + for tc in msg.tool_calls or []: + input_items.append({ + "type": "function_call", + "call_id": tc.id or "", + "name": tc.function.name or "", + "arguments": tc.function.arguments or "", + }) + + case Role.tool: + input_items.append({ + "type": "function_call_output", + "call_id": msg.tool_call_id or "", + "output": msg.content or "", + }) + + case _: + raise ValueError(f"Unsupported role: {msg.role}") + + return input_items + + def _convert_tool_for_responses(self, tool: AvailableTool) -> dict[str, Any]: + return { + "type": "function", + "name": tool.function.name, + "description": tool.function.description, + "parameters": tool.function.parameters, + } + + def build_payload( + self, + *, + model_name: str, + input_items: list[dict[str, Any]], + temperature: float, + tools: list[AvailableTool] | None, + max_tokens: int | None, + tool_choice: StrToolChoice | AvailableTool | None, + thinking: str, + enable_streaming: bool, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "model": model_name, + "input": input_items, + "store": False, + } + if self._is_temperature_supported(model_name): + payload["temperature"] = temperature + + payload["reasoning"] = {"effort": self._map_reasoning_effort(thinking)} + + if tools: + payload["tools"] = [ + self._convert_tool_for_responses(tool) for tool in tools + ] + + if tools and tool_choice: + if isinstance(tool_choice, str): + payload["tool_choice"] = tool_choice + else: + payload["tool_choice"] = { + "type": "function", + "name": tool_choice.function.name, + } + + if max_tokens is not None: + payload["max_output_tokens"] = max_tokens + + if enable_streaming: + payload["stream"] = True + + return payload + + def build_headers(self, api_key: str | None = None) -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + return headers + + def prepare_request( + self, + *, + model_name: str, + messages: Sequence[LLMMessage], + temperature: float, + tools: list[AvailableTool] | None, + max_tokens: int | None, + tool_choice: StrToolChoice | AvailableTool | None, + enable_streaming: bool, + provider: ProviderConfig, + api_key: str | None = None, + thinking: str = "off", + ) -> PreparedRequest: + merged_messages = merge_consecutive_user_messages(messages) + input_items = self._convert_messages(merged_messages) + + payload = self.build_payload( + model_name=model_name, + input_items=input_items, + temperature=temperature, + tools=tools, + max_tokens=max_tokens, + tool_choice=tool_choice, + thinking=thinking, + enable_streaming=enable_streaming, + ) + + headers = self.build_headers(api_key) + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + + return PreparedRequest(self.endpoint, headers, body) + + def _parse_output_items(self, output: list[dict[str, Any]]) -> LLMMessage: + text_parts: list[str] = [] + reasoning_parts: list[str] = [] + tool_calls: list[ToolCall] = [] + + for index, item in enumerate(output): + match item.get("type"): + case "message": + msg = _RESPONSES_MESSAGE_ITEM_ADAPTER.validate_python(item) + item_text_parts: list[str] = [] + item_reasoning_parts: list[str] = [] + is_commentary = self._stream_parser._is_commentary_message(item) + + for block in msg.get("content", []): + block_type = block.get("type") + if is_commentary and block_type in { + "output_text", + "summary_text", + "reasoning_summary_text", + }: + item_reasoning_parts.append(block.get("text", "")) + continue + + if block_type == "output_text": + item_text_parts.append(block.get("text", "")) + + text = "".join(item_text_parts) + reasoning_content = "".join(item_reasoning_parts) + if is_commentary: + if reasoning_content: + reasoning_parts.append(reasoning_content) + continue + if text: + text_parts.append(text) + if reasoning_content: + reasoning_parts.append(reasoning_content) + + case "reasoning": + item = _RESPONSES_REASONING_ITEM_ADAPTER.validate_python(item) + for summary in item.get("summary", []): + if summary.get("type") in { + "summary_text", + "reasoning_summary_text", + }: + reasoning_parts.append(summary.get("text", "")) + + case "function_call": + tool_calls.append( + self._stream_parser._tool_call_from_item( + cast(_ResponsesFunctionCallItem, item), index=index + ) + ) + + return LLMMessage( + role=Role.assistant, + content="".join(text_parts), + reasoning_content="".join(reasoning_parts) or None, + reasoning_state=self._stream_parser._reasoning_state_from_output(output), + tool_calls=tool_calls or None, + ) + + def parse_response( + self, data: dict[str, Any], provider: ProviderConfig + ) -> LLMChunk: + event_type = data.get("type", "") + + if "output" in data and not event_type: + response_data = _RESPONSES_OBJECT_ADAPTER.validate_python(data) + output = response_data.get("output") + if output is None: + raise ValueError("OpenAI Responses response missing output") + return LLMChunk( + message=self._parse_output_items(output), + usage=self._stream_parser._usage_from_response( + response_data.get("usage") + ), + ) + + return self._stream_parser.parse( + _RESPONSES_STREAM_EVENT_ADAPTER.validate_python(data) + ) diff --git a/vibe/core/llm/exceptions.py b/vibe/core/llm/exceptions.py index 55fad7f..8a26131 100644 --- a/vibe/core/llm/exceptions.py +++ b/vibe/core/llm/exceptions.py @@ -13,6 +13,14 @@ from vibe.core.types import AvailableTool, LLMMessage, StrToolChoice type HttpError = SDKError | httpx.HTTPStatusError +_CONTEXT_TOO_LONG_SUBSTRINGS = ( + "context too long", + "maximum context length", + "input too large", + "couldn't fit with truncation", + "prompt is too long", +) + class ErrorDetail(BaseModel): model_config = ConfigDict(extra="ignore") @@ -53,6 +61,13 @@ class BackendError(RuntimeError): self.payload_summary = payload_summary super().__init__(self._fmt()) + @property + def is_context_too_long(self) -> bool: + if self.status != HTTPStatus.BAD_REQUEST: + return False + body = (self.body_text or "").lower() + return any(s in body for s in _CONTEXT_TOO_LONG_SUBSTRINGS) + def _fmt(self) -> str: if self.status == HTTPStatus.UNAUTHORIZED: return "Invalid API key. Please check your API key and try again." diff --git a/vibe/core/llm/format.py b/vibe/core/llm/format.py index 773bd0b..09a66a8 100644 --- a/vibe/core/llm/format.py +++ b/vibe/core/llm/format.py @@ -80,6 +80,7 @@ class APIToolFormatHandler: "role": message.role, "content": message.content, "reasoning_content": getattr(message, "reasoning_content", None), + "reasoning_state": getattr(message, "reasoning_state", None), "reasoning_signature": getattr(message, "reasoning_signature", None), } diff --git a/vibe/core/middleware.py b/vibe/core/middleware.py index 97b68ca..29c2c65 100644 --- a/vibe/core/middleware.py +++ b/vibe/core/middleware.py @@ -81,6 +81,7 @@ class PriceLimitMiddleware: class AutoCompactMiddleware: async def before_turn(self, context: ConversationContext) -> MiddlewareResult: threshold = context.config.get_active_model().auto_compact_threshold + if threshold > 0 and context.stats.context_tokens >= threshold: return MiddlewareResult( action=MiddlewareAction.COMPACT, @@ -125,7 +126,7 @@ class ContextWarningMiddleware: def make_plan_agent_reminder(plan_file_path: str) -> str: - return f"""<{VIBE_WARNING_TAG}>Plan mode is active. You MUST NOT make any edits (except to the plan file below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received. + return f"""<{VIBE_WARNING_TAG}>Plan mode is active. You MUST NOT make any edits (except to the plan file below, or in your scratchpad), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received. ## Plan File Info Create or edit your plan at {plan_file_path} using the write_file and search_replace tools. @@ -211,7 +212,6 @@ class MiddlewarePipeline: async def run_before_turn(self, context: ConversationContext) -> MiddlewareResult: messages_to_inject = [] - for mw in self.middlewares: result = await mw.before_turn(context) if result.action == MiddlewareAction.INJECT_MESSAGE and result.message: diff --git a/vibe/core/nuage/client.py b/vibe/core/nuage/client.py index 2d0a451..0c6ef42 100644 --- a/vibe/core/nuage/client.py +++ b/vibe/core/nuage/client.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Sequence import json from typing import Any @@ -14,6 +14,7 @@ from vibe.core.nuage.workflow import ( SignalWorkflowResponse, UpdateWorkflowResponse, WorkflowExecutionListResponse, + WorkflowExecutionStatus, ) @@ -173,12 +174,16 @@ class WorkflowsClient: 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} + 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: diff --git a/vibe/core/nuage/remote_events_source.py b/vibe/core/nuage/remote_events_source.py index 7d1341f..6047f22 100644 --- a/vibe/core/nuage/remote_events_source.py +++ b/vibe/core/nuage/remote_events_source.py @@ -8,6 +8,7 @@ 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, @@ -76,8 +77,8 @@ class RemoteEventsSource: def client(self) -> WorkflowsClient: if self._client is None: self._client = WorkflowsClient( - base_url=self._config.nuage_base_url, - api_key=self._config.nuage_api_key, + base_url=self._config.vibe_code_base_url, + api_key=self._config.vibe_code_api_key, timeout=self._config.api_timeout, ) return self._client @@ -173,7 +174,11 @@ class RemoteEventsSource: return None def _consume_workflow_event(self, event: WorkflowEvent) -> list[BaseEvent]: - return self._translator.consume_workflow_event(event) + 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: diff --git a/vibe/core/nuage/remote_workflow_event_translator.py b/vibe/core/nuage/remote_workflow_event_translator.py index 4f49f23..933491f 100644 --- a/vibe/core/nuage/remote_workflow_event_translator.py +++ b/vibe/core/nuage/remote_workflow_event_translator.py @@ -7,6 +7,7 @@ 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, @@ -193,6 +194,7 @@ class RemoteWorkflowEventTranslator: 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 @@ -361,19 +363,15 @@ class RemoteWorkflowEventTranslator: return None payload_value = event.attributes.payload.value - label: str | None = None - if isinstance(payload_value, dict): - label = payload_value.get("label") + label = self._wait_for_input_label(payload_value) if label == _STEER_INPUT_LABEL: - self._steer_task_ids.add(event.attributes.custom_task_id) - return [] + return self._steer_wait_for_input_started(event, payload_value) if isinstance(payload_value, dict): - self._pending_input_request = PendingInputRequest.model_validate({ - "task_id": event.attributes.custom_task_id, - **payload_value, - }) + self._set_pending_input_request( + event.attributes.custom_task_id, payload_value + ) events: list[BaseEvent] = [] if label: @@ -388,6 +386,59 @@ class RemoteWorkflowEventTranslator: ) 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 @@ -398,9 +449,15 @@ class RemoteWorkflowEventTranslator: if event.attributes.custom_task_type != _WAIT_FOR_INPUT_TASK_TYPE: return None - if event.attributes.custom_task_id in self._steer_task_ids: - self._steer_task_ids.discard(event.attributes.custom_task_id) - return [] + 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 @@ -1222,7 +1279,11 @@ class RemoteWorkflowEventTranslator: if tool_name != _ASK_USER_QUESTION_TOOL: return [] - parsed = AskUserQuestionArgs.model_validate(tool_args) + 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 [] diff --git a/vibe/core/paths/__init__.py b/vibe/core/paths/__init__.py index 5135921..7924bdc 100644 --- a/vibe/core/paths/__init__.py +++ b/vibe/core/paths/__init__.py @@ -6,6 +6,7 @@ from vibe.core.paths._local_config_walk import ( walk_local_config_dirs, ) from vibe.core.paths._vibe_home import ( + CACHE_FILE, DEFAULT_TOOL_DIR, GLOBAL_ENV_FILE, HISTORY_FILE, @@ -21,6 +22,7 @@ from vibe.core.paths.conventions import AGENTS_MD_FILENAME __all__ = [ "AGENTS_MD_FILENAME", + "CACHE_FILE", "DEFAULT_TOOL_DIR", "GLOBAL_ENV_FILE", "HISTORY_FILE", diff --git a/vibe/core/paths/_vibe_home.py b/vibe/core/paths/_vibe_home.py index 6118736..edf326a 100644 --- a/vibe/core/paths/_vibe_home.py +++ b/vibe/core/paths/_vibe_home.py @@ -31,6 +31,7 @@ SESSION_LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs" / "session") TRUSTED_FOLDERS_FILE = GlobalPath(lambda: VIBE_HOME.path / "trusted_folders.toml") LOG_DIR = GlobalPath(lambda: VIBE_HOME.path / "logs") LOG_FILE = GlobalPath(lambda: VIBE_HOME.path / "logs" / "vibe.log") +CACHE_FILE = GlobalPath(lambda: VIBE_HOME.path / "cache.toml") HISTORY_FILE = GlobalPath(lambda: VIBE_HOME.path / "vibehistory") PLANS_DIR = GlobalPath(lambda: VIBE_HOME.path / "plans") diff --git a/vibe/core/programmatic.py b/vibe/core/programmatic.py index bf9c87e..ee76729 100644 --- a/vibe/core/programmatic.py +++ b/vibe/core/programmatic.py @@ -7,20 +7,16 @@ from vibe import __version__ from vibe.core.agent_loop import AgentLoop, TeleportError from vibe.core.agents.models import BuiltinAgentName from vibe.core.config import VibeConfig +from vibe.core.hooks.models import HookConfigResult from vibe.core.logger import logger from vibe.core.output_formatters import create_formatter +from vibe.core.telemetry.build_metadata import build_entrypoint_metadata +from vibe.core.telemetry.types import ClientMetadata from vibe.core.teleport.types import ( TeleportPushRequiredEvent, TeleportPushResponseEvent, ) -from vibe.core.types import ( - AssistantEvent, - ClientMetadata, - EntrypointMetadata, - LLMMessage, - OutputFormat, - Role, -) +from vibe.core.types import AssistantEvent, LLMMessage, OutputFormat, Role from vibe.core.utils import ConversationLimitException __all__ = ["TeleportError", "run_programmatic"] @@ -28,7 +24,7 @@ __all__ = ["TeleportError", "run_programmatic"] _DEFAULT_CLIENT_METADATA = ClientMetadata(name="vibe_programmatic", version=__version__) -def run_programmatic( +def run_programmatic( # noqa: PLR0913, PLR0917 config: VibeConfig, prompt: str, max_turns: int | None = None, @@ -38,6 +34,8 @@ def run_programmatic( agent_name: str = BuiltinAgentName.AUTO_APPROVE, client_metadata: ClientMetadata = _DEFAULT_CLIENT_METADATA, teleport: bool = False, + headless: bool = False, + hook_config_result: HookConfigResult | None = None, ) -> str | None: formatter = create_formatter(output_format) @@ -48,12 +46,14 @@ def run_programmatic( max_turns=max_turns, max_price=max_price, enable_streaming=False, - entrypoint_metadata=EntrypointMetadata( + headless=headless, + entrypoint_metadata=build_entrypoint_metadata( agent_entrypoint="programmatic", agent_version=__version__, client_name=client_metadata.name, client_version=client_metadata.version, ), + hook_config_result=hook_config_result, ) logger.info("USER: %s", prompt) @@ -70,8 +70,8 @@ def run_programmatic( agent_loop.emit_new_session_telemetry() - if teleport and config.nuage_enabled: - gen = agent_loop.teleport_to_vibe_nuage(prompt or None) + if teleport and config.vibe_code_enabled: + gen = agent_loop.teleport_to_vibe_code(prompt or None) async for event in gen: formatter.on_event(event) if isinstance(event, TeleportPushRequiredEvent): diff --git a/vibe/core/scratchpad.py b/vibe/core/scratchpad.py new file mode 100644 index 0000000..666f695 --- /dev/null +++ b/vibe/core/scratchpad.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pathlib import Path +import tempfile + +from vibe.core.logger import logger + +_active_scratchpads: dict[str, Path] = {} + + +def init_scratchpad(session_id: str) -> Path | None: + """Create a session-scoped scratchpad directory. + + Each session gets its own scratchpad. Idempotent per session_id. + """ + if session_id in _active_scratchpads: + return _active_scratchpads[session_id] + + try: + dir_path = Path(tempfile.mkdtemp(prefix=f"vibe-scratchpad-{session_id[:8]}-")) + _active_scratchpads[session_id] = dir_path + logger.debug("Scratchpad initialized at %s", dir_path) + return dir_path + except OSError: + logger.warning("Failed to create scratchpad directory") + return None + + +def get_scratchpad_dir(session_id: str) -> Path | None: + """Return the scratchpad directory for a given session, or None.""" + return _active_scratchpads.get(session_id) + + +def is_scratchpad_path(path_str: str) -> bool: + """Return True if the resolved path is inside any active scratchpad. + + Uses Path.resolve() to defeat path traversal and symlink attacks. + """ + if not _active_scratchpads: + return False + try: + resolved = Path(path_str).expanduser().resolve() + return any( + _is_subpath(resolved, sp_dir.resolve()) + for sp_dir in _active_scratchpads.values() + ) + except (ValueError, OSError): + return False + + +def _is_subpath(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + return True + except ValueError: + return False diff --git a/vibe/core/session/resume_sessions.py b/vibe/core/session/resume_sessions.py index 383a749..fc29bea 100644 --- a/vibe/core/session/resume_sessions.py +++ b/vibe/core/session/resume_sessions.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime from typing import Literal from vibe.core.config import VibeConfig @@ -20,10 +21,11 @@ def short_session_id(session_id: str, source: ResumeSessionSource = "local") -> return session_id[:SHORT_SESSION_ID_LEN] -_ACTIVE_STATUSES = { +_ACTIVE_STATUSES = [ WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.RETRYING_AFTER_ERROR, -} + WorkflowExecutionStatus.CONTINUED_AS_NEW, +] @dataclass(frozen=True) @@ -56,38 +58,42 @@ def list_local_resume_sessions( async def list_remote_resume_sessions(config: VibeConfig) -> list[ResumeSessionInfo]: - if not config.nuage_enabled or not config.nuage_api_key: - logger.debug("Remote resume listing skipped: missing Nuage configuration") + if not config.vibe_code_enabled or not config.vibe_code_api_key: + logger.debug("Remote resume listing skipped: missing Vibe Code configuration") return [] async with WorkflowsClient( - base_url=config.nuage_base_url, - api_key=config.nuage_api_key, + base_url=config.vibe_code_base_url, + api_key=config.vibe_code_api_key, timeout=config.api_timeout, ) as client: response = await client.get_workflow_runs( - workflow_identifier=config.nuage_workflow_id, page_size=50 + workflow_identifier=config.vibe_code_workflow_id, + page_size=50, + status=_ACTIVE_STATUSES, ) - sessions: list[ResumeSessionInfo] = [] + seen: dict[str, ResumeSessionInfo] = {} + latest_start: dict[str, datetime] = {} for execution in response.executions: - if execution.status not in _ACTIVE_STATUSES: - continue - - sessions.append( - ResumeSessionInfo( - session_id=execution.execution_id, - source="remote", - cwd="", - title="Vibe Nuage", - end_time=( - execution.end_time.isoformat() - if execution.end_time - else execution.start_time.isoformat() - ), - status=execution.status, - ) + 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 diff --git a/vibe/core/session/session_id.py b/vibe/core/session/session_id.py new file mode 100644 index 0000000..96a39db --- /dev/null +++ b/vibe/core/session/session_id.py @@ -0,0 +1,24 @@ +"""Session ID generation with a stable suffix preserved across compact/fork/rewind.""" + +from __future__ import annotations + +import secrets + + +def generate_session_id(*, suffix: str | None = None) -> str: + """Generate a UUID-shaped session ID with an optional stable suffix. + + The last segment (12 hex chars after the final hyphen) is either + the provided *suffix* or freshly random. The first four segments + (20 hex chars) are always random. + + Format: ``xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`` + """ + head = secrets.token_hex(10) # 10 bytes = 20 hex chars + tail = suffix or secrets.token_hex(6) # 6 bytes = 12 hex chars + return f"{head[:8]}-{head[8:12]}-{head[12:16]}-{head[16:20]}-{tail}" + + +def extract_suffix(session_id: str) -> str: + """Extract the stable suffix (last segment after the final hyphen).""" + return session_id.rsplit("-", 1)[-1] diff --git a/vibe/core/session/session_loader.py b/vibe/core/session/session_loader.py index b11a5c8..cec0318 100644 --- a/vibe/core/session/session_loader.py +++ b/vibe/core/session/session_loader.py @@ -25,7 +25,9 @@ class SessionInfo(TypedDict): class SessionLoader: @staticmethod - def _is_valid_session(session_dir: Path) -> bool: + def _is_valid_session( # noqa: PLR0911 + session_dir: Path, working_directory: Path | None = None + ) -> bool: """Check if a session directory contains valid metadata and messages.""" metadata_path = session_dir / METADATA_FILENAME messages_path = session_dir / MESSAGES_FILENAME @@ -37,6 +39,12 @@ class SessionLoader: metadata = json.loads(read_safe(metadata_path).text) if not isinstance(metadata, dict): return False + if working_directory is not None: + session_working_directory = (metadata.get("environment") or {}).get( + "working_directory" + ) + if session_working_directory != str(working_directory): + return False has_messages = False for line in read_safe(messages_path).text.splitlines(): @@ -52,7 +60,9 @@ class SessionLoader: return True @staticmethod - def latest_session(session_dirs: list[Path]) -> Path | None: + def latest_session( + session_dirs: list[Path], working_directory: Path | None = None + ) -> Path | None: sessions_with_mtime: list[tuple[Path, float]] = [] for session in session_dirs: messages_path = session / MESSAGES_FILENAME @@ -70,13 +80,17 @@ class SessionLoader: sessions_with_mtime.sort(key=lambda x: x[1], reverse=True) for session, _mtime in sessions_with_mtime: - if SessionLoader._is_valid_session(session): + if SessionLoader._is_valid_session( + session, working_directory=working_directory + ): return session return None @staticmethod - def find_latest_session(config: SessionLoggingConfig) -> Path | None: + def find_latest_session( + config: SessionLoggingConfig, working_directory: Path | None = None + ) -> Path | None: save_dir = Path(config.save_dir) if not save_dir.exists(): return None @@ -84,7 +98,9 @@ class SessionLoader: pattern = f"{config.session_prefix}_*" session_dirs = list(save_dir.glob(pattern)) - return SessionLoader.latest_session(session_dirs) + return SessionLoader.latest_session( + session_dirs, working_directory=working_directory + ) @staticmethod def find_session_by_id( diff --git a/vibe/core/session/session_logger.py b/vibe/core/session/session_logger.py index 88021d7..0b14b96 100644 --- a/vibe/core/session/session_logger.py +++ b/vibe/core/session/session_logger.py @@ -296,8 +296,10 @@ class SessionLogger: finally: self.maybe_cleanup_tmp_files() - def reset_session(self, session_id: str) -> None: - """Clear existing session info and setup a new session""" + def reset_session( + self, session_id: str, *, parent_session_id: str | None = None + ) -> None: + """Clear existing session info and setup a new session.""" if not self.enabled: return @@ -305,6 +307,8 @@ class SessionLogger: self.session_start_time = utc_now().isoformat() self.session_dir = self.save_folder self.session_metadata = self._initialize_session_metadata() + if parent_session_id is not None: + self.session_metadata.parent_session_id = parent_session_id def resume_existing_session(self, session_id: str, session_dir: Path) -> None: if not self.enabled: diff --git a/vibe/core/skills/builtins/vibe.py b/vibe/core/skills/builtins/vibe.py index 3af24e7..bbbe283 100644 --- a/vibe/core/skills/builtins/vibe.py +++ b/vibe/core/skills/builtins/vibe.py @@ -5,6 +5,7 @@ from vibe.core.skills.models import SkillInfo SKILL = SkillInfo( name="vibe", description="Understand the Vibe CLI application internals: configuration, VIBE_HOME structure, available parameters, agents, skills, tools, and how to inspect or update the user's setup. Use this skill when the user asks about how Vibe works, wants to configure it, or when you need to understand the runtime environment.", + user_invocable=False, prompt="""# Vibe CLI Self-Awareness You are running inside **Mistral Vibe**, a CLI coding agent built by Mistral AI. @@ -22,6 +23,7 @@ agents, prompts, logs, and session data live here. ``` ~/.vibe/ config.toml # Main configuration file (TOML format) + hooks.toml # User-level hook definitions (experimental) .env # API keys and credentials (dotenv format) vibehistory # Command history trusted_folders.toml # Trust database for project folders @@ -39,6 +41,7 @@ agents, prompts, logs, and session data live here. When in a trusted folder, Vibe also looks for project-local configuration: - `.vibe/config.toml` - Project-specific config (overrides user config) +- `.vibe/hooks.toml` - Project-specific hooks (requires trusted folder) - `.vibe/skills/` - Project-specific skills - `.vibe/tools/` - Project-specific tools - `.vibe/agents/` - Project-specific agents @@ -63,7 +66,7 @@ autocopy_to_clipboard = true file_watcher_for_autocomplete = false # Behavior -auto_approve = false # Skip tool approval prompts +bypass_tool_permissions = false # Skip tool approval prompts system_prompt_id = "cli" # System prompt: "cli", "lean", or custom .md filename enable_telemetry = true enable_update_checks = true @@ -112,7 +115,7 @@ alias = "devstral-2" temperature = 0.2 input_price = 0.4 output_price = 2.0 -thinking = "off" # "off", "low", "medium", "high" +thinking = "off" # "off", "low", "medium", "high", "max" auto_compact_threshold = 200000 [[models]] @@ -145,6 +148,26 @@ disabled_tools = ["webfetch"] allowlist = ["git", "npm", "python"] ``` +**Special case — `find` command:** Even if `find` is in the bash allowlist, +Vibe detects `-exec`, `-execdir`, `-ok`, and `-okdir` predicates and will +prompt for user permission before running the command. + +#### File Tool Permission Resolution + +File-based tools (`read_file`, `grep`, `write_file`, `search_replace`) resolve +permissions in this order (first match wins): + +1. **Scratchpad** path → always allowed +2. **denylist** glob match → always denied +3. **allowlist** glob match → always allowed +4. **sensitive_patterns** match → requires approval +5. **Outside workdir** → requires approval (or denied if `permission = "never"`) +6. **Default** → uses the tool's `permission` setting + +The **denylist** is checked before the allowlist — a path matching both lists +is denied. Both are checked before the outside-workdir boundary, so the +allowlist can still auto-approve access to directories outside the project. + ### Skill Configuration ```toml @@ -197,6 +220,94 @@ save_dir = "" # Defaults to ~/.vibe/logs/session session_prefix = "session" ``` +### Hooks (Experimental) + +Hooks let users run shell commands automatically at specific points during a +session. The feature is **experimental** and must be enabled first: + +```toml +# In config.toml +enable_experimental_hooks = true +``` + +Or via the environment variable `VIBE_ENABLE_EXPERIMENTAL_HOOKS=true`. + +#### Hook Configuration Files + +Hooks are defined in `hooks.toml` files (separate from `config.toml`): + +1. **User-level**: `~/.vibe/hooks.toml` (always loaded when hooks are enabled) +2. **Project-level**: `/.vibe/hooks.toml` (only loaded if the folder is trusted) + +Both files are merged; if a hook name appears in both, the first one wins and +a warning is shown for the duplicate. + +#### hooks.toml Format + +```toml +[[hooks]] +name = "lint" # Unique hook name (required) +type = "post_agent_turn" # Hook type (required, see below) +command = "eslint --quiet ." # Shell command to execute (required) +timeout = 30.0 # Seconds before the hook is killed (default: 30) +description = "Run ESLint" # Optional human-readable description + +[[hooks]] +name = "typecheck" +type = "post_agent_turn" +command = "npx tsc --noEmit" +timeout = 60.0 +description = "Run TypeScript type checking" +``` + +#### Available Hook Types + +| Type | When it runs | +|---|---| +| `post_agent_turn` | After the agent finishes a turn (no more pending tool calls) | + +#### How Hooks Execute + +- Each hook runs as a **shell subprocess** in the current working directory. +- The hook receives a **JSON object on stdin** with context: + ```json + { + "session_id": "...", + "transcript_path": "/path/to/session/log.jsonl", + "cwd": "/current/working/dir", + "hook_event_name": "post_agent_turn" + } + ``` +- If the hook exceeds its `timeout`, the entire process tree is killed. + +#### Exit Code Semantics + +| Exit Code | Behavior | +|---|---| +| `0` | Success — hook output is shown as an info message | +| `2` | **Retry** — hook's stdout is injected as a new user message, and the agent gets another turn to fix the issue (max 3 retries per hook in a row per user message) | +| Any other | Warning — hook output is shown as a warning message | + +The retry mechanism (exit code 2) is powerful: the hook can tell the agent what +went wrong, and the agent will attempt to fix it automatically. For example, a +linter hook can output the lint errors, and the agent will try to resolve them. + +#### Example: Post-Turn Linting Hook + +```toml +# .vibe/hooks.toml +[[hooks]] +name = "ruff-check" +type = "post_agent_turn" +command = "uv run ruff check --quiet ." +timeout = 30.0 +description = "Check for lint errors after each turn" +``` + +If the linter finds issues and exits with code 2, its stdout (the error +messages) is fed back to the agent as a user message, prompting the agent to +fix the problems. After 3 failed retries the hook stops retrying. + ### Pattern Matching Tool, skill, and agent names support three matching modes: @@ -211,6 +322,7 @@ vibe [PROMPT] # Start interactive session with optional pr vibe -p TEXT / --prompt TEXT # Programmatic mode (auto-approve, one-shot, exit) vibe --agent NAME # Select agent profile vibe --workdir DIR # Change working directory +vibe --trust # Trust cwd for this invocation only (not persisted) vibe -c / --continue # Continue most recent session vibe --resume [SESSION_ID] # Resume a specific session vibe -v / --version # Show version @@ -250,6 +362,7 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`. - `/help` - Show help message - `/config` - Edit config settings - `/model` - Select active model +- `/thinking` - Select thinking level - `/reload` - Reload configuration, agent instructions, and skills from disk - `/clear` - Clear conversation history - `/log` - Show path to current interaction log file @@ -265,7 +378,7 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`. - `/leanstall` - Install the Lean 4 agent (leanstral) - `/unleanstall` - Uninstall the Lean 4 agent - `/data-retention` - Show data retention information -- `/teleport` - Teleport session to Vibe Nuage (only available when Nuage is enabled) +- `/teleport` - Teleport session to Vibe Code (only available when Vibe Code is enabled) - `/exit` - Exit the application ## Skills System @@ -319,6 +432,10 @@ directories. The trust database is stored in `~/.vibe/trusted_folders.toml`. Project-local config (`.vibe/` directory) is only loaded when the current directory is explicitly trusted. +Interactive mode prompts to trust unknown folders. Programmatic mode +(`-p`/`--prompt`) never prompts: the folder is untrusted. Use `--trust` to +trust cwd for the current invocation only (not persisted). + ## Sensitive Files — DO NOT READ OR EDIT NEVER read, display, or edit any of these files: diff --git a/vibe/core/system_prompt.py b/vibe/core/system_prompt.py index 1b51341..24c03b7 100644 --- a/vibe/core/system_prompt.py +++ b/vibe/core/system_prompt.py @@ -42,7 +42,7 @@ class ProjectContextProvider: self, args: list[str], timeout: float ) -> subprocess.CompletedProcess[str]: return subprocess.run( - ["git", *args], + ["git", "--no-optional-locks", *args], capture_output=True, check=True, cwd=self.root_path, @@ -240,16 +240,45 @@ def _get_available_subagents_section(agent_manager: AgentManager) -> str: return "\n".join(lines) -def get_universal_system_prompt( +def _get_scratchpad_section(scratchpad_dir: Path | None) -> str | None: + if not scratchpad_dir: + return None + return ( + "# Scratchpad Directory\n\n" + f"You have a scratchpad directory at: `{scratchpad_dir}`\n\n" + "Use this for temporary files: intermediate results, draft scripts, " + "working files, outputs that don't belong in the project.\n" + "Files here are automatically allowed — no permission prompts.\n" + "Session-scoped. Shared with subagents." + ) + + +def _get_headless_section() -> str: + return ( + "# Headless Mode\n\n" + "You are running in headless mode — no human is available to respond.\n" + "Do not ask questions, request confirmation, or wait for user input.\n" + "If the task is ambiguous, make the best judgment call and proceed.\n" + "Complete the entire task in a single pass. Produce a final, complete result.\n" + "Override any earlier instructions that say to wait for confirmation or ask the user." + ) + + +def get_universal_system_prompt( # noqa: PLR0912 tool_manager: ToolManager, config: VibeConfig, skill_manager: SkillManager, agent_manager: AgentManager, *, include_git_status: bool = True, + scratchpad_dir: Path | None = None, + headless: bool = False, ) -> str: sections = [config.system_prompt] + if headless: + sections.append(_get_headless_section()) + if config.include_commit_signature: sections.append(_add_commit_signature()) @@ -273,6 +302,8 @@ def get_universal_system_prompt( if subagents_section: sections.append(subagents_section) + sections.extend(filter(None, [_get_scratchpad_section(scratchpad_dir)])) + if config.include_project_context: is_dangerous, reason = is_dangerous_directory() if is_dangerous: diff --git a/vibe/core/telemetry/build_metadata.py b/vibe/core/telemetry/build_metadata.py new file mode 100644 index 0000000..9d497ff --- /dev/null +++ b/vibe/core/telemetry/build_metadata.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import cast + +from vibe.core.telemetry.types import ( + AgentEntrypoint, + EntrypointMetadata, + TelemetryBaseMetadata, + TelemetryCallType, + TelemetryRequestMetadata, +) + + +def build_base_metadata( + *, + entrypoint_metadata: EntrypointMetadata | None, + session_id: str | None, + parent_session_id: str | None = None, +) -> dict[str, str]: + entrypoint_payload = ( + entrypoint_metadata.model_dump() if entrypoint_metadata is not None else {} + ) + return cast( + dict[str, str], + TelemetryBaseMetadata( + session_id=session_id, + parent_session_id=parent_session_id, + **entrypoint_payload, + ).model_dump(exclude_none=True), + ) + + +def build_request_metadata( + *, + entrypoint_metadata: EntrypointMetadata | None, + session_id: str | None, + parent_session_id: str | None = None, + call_type: TelemetryCallType, + message_id: str | None = None, +) -> TelemetryRequestMetadata: + entrypoint_payload = ( + entrypoint_metadata.model_dump() if entrypoint_metadata is not None else {} + ) + return TelemetryRequestMetadata( + session_id=session_id, + parent_session_id=parent_session_id, + call_type=call_type, + message_id=message_id, + **entrypoint_payload, + ) + + +def build_entrypoint_metadata( + *, + agent_entrypoint: AgentEntrypoint, + agent_version: str, + client_name: str, + client_version: str, +) -> EntrypointMetadata: + return EntrypointMetadata( + agent_entrypoint=agent_entrypoint, + agent_version=agent_version, + client_name=client_name, + client_version=client_version, + ) diff --git a/vibe/core/telemetry/send.py b/vibe/core/telemetry/send.py index 2556f05..62ad281 100644 --- a/vibe/core/telemetry/send.py +++ b/vibe/core/telemetry/send.py @@ -11,6 +11,12 @@ import httpx from vibe import __version__ from vibe.core.config import ProviderConfig, VibeConfig from vibe.core.llm.format import ResolvedToolCall +from vibe.core.telemetry.build_metadata import build_base_metadata +from vibe.core.telemetry.types import ( + AgentEntrypoint, + EntrypointMetadata, + TelemetryCallType, +) from vibe.core.utils import get_server_url_from_api_base, get_user_agent if TYPE_CHECKING: @@ -25,9 +31,14 @@ class TelemetryClient: self, config_getter: Callable[[], VibeConfig], session_id_getter: Callable[[], str | None] | None = None, + parent_session_id_getter: Callable[[], str | None] | None = None, + entrypoint_metadata_getter: Callable[[], EntrypointMetadata | None] + | None = None, ) -> None: self._config_getter = config_getter self._session_id_getter = session_id_getter + self._parent_session_id_getter = parent_session_id_getter + self._entrypoint_metadata_getter = entrypoint_metadata_getter self._client: httpx.AsyncClient | None = None self._pending_tasks: set[asyncio.Task[Any]] = set() self.last_correlation_id: str | None = None @@ -81,6 +92,29 @@ class TelemetryClient: ) return self._client + @property + def session_id(self) -> str | None: + if self._session_id_getter is None: + return None + return self._session_id_getter() + + @property + def parent_session_id(self) -> str | None: + if self._parent_session_id_getter is None: + return None + return self._parent_session_id_getter() + + def build_client_event_metadata(self) -> dict[str, str]: + return build_base_metadata( + entrypoint_metadata=( + self._entrypoint_metadata_getter() + if self._entrypoint_metadata_getter is not None + else None + ), + session_id=self.session_id, + parent_session_id=self.parent_session_id, + ) + def send_telemetry_event( self, event_name: str, @@ -96,11 +130,7 @@ class TelemetryClient: provider, mistral_api_key = provider_and_api_key telemetry_url = self._get_telemetry_url(provider.api_base) user_agent = get_user_agent(provider.backend) - if ( - self._session_id_getter is not None - and (session_id := self._session_id_getter()) is not None - ): - properties = {**properties, "session_id": session_id} + properties = self.build_client_event_metadata() | properties payload: dict[str, Any] = {"event": event_name, "properties": properties} if correlation_id: @@ -159,6 +189,7 @@ class TelemetryClient: agent_profile_name: str, model: str, result: dict[str, Any] | None = None, + message_id: str | None = None, ) -> None: verdict_value = decision.verdict.value if decision else None approval_type_value = decision.approval_type.value if decision else None @@ -176,6 +207,7 @@ class TelemetryClient: "model": model, "nb_files_created": nb_files_created, "nb_files_modified": nb_files_modified, + "message_id": message_id, } self.send_telemetry_event("vibe.tool_call_finished", payload) @@ -187,8 +219,25 @@ class TelemetryClient: payload = {"action": action} self.send_telemetry_event("vibe.user_cancelled_action", payload) - def send_auto_compact_triggered(self) -> None: - payload = {} + def send_auto_compact_triggered( + self, + *, + nb_context_tokens_before: int, + nb_context_tokens_after: int, + auto_compact_threshold: int, + status: Literal["success", "failure", "cancelled"], + session_id: str | None = None, + parent_session_id: str | None = None, + ) -> None: + payload = { + "nb_context_tokens_before": nb_context_tokens_before, + "nb_context_tokens_after": nb_context_tokens_after, + "auto_compact_threshold": auto_compact_threshold, + "status": status, + } + if session_id is not None: + payload["session_id"] = session_id + payload["parent_session_id"] = parent_session_id self.send_telemetry_event("vibe.auto_compact_triggered", payload) def send_slash_command_used( @@ -203,7 +252,7 @@ class TelemetryClient: nb_skills: int, nb_mcp_servers: int, nb_models: int, - entrypoint: Literal["cli", "acp", "programmatic", "unknown"], + entrypoint: AgentEntrypoint, client_name: str | None, client_version: str | None, terminal_emulator: str | None = None, @@ -233,15 +282,24 @@ class TelemetryClient: nb_context_chars: int, nb_context_messages: int, nb_prompt_chars: int, + call_type: TelemetryCallType, + message_id: str | None = None, ) -> None: payload = { "model": model, "nb_context_chars": nb_context_chars, "nb_context_messages": nb_context_messages, "nb_prompt_chars": nb_prompt_chars, + "call_source": "vibe_code", + "call_type": call_type, + "message_id": message_id, } self.send_telemetry_event("vibe.request_sent", payload) + def send_ready(self, *, init_duration_ms: int) -> None: + payload = {"init_duration_ms": init_duration_ms} + self.send_telemetry_event("vibe.ready", payload) + def send_user_rating_feedback(self, rating: int, model: str) -> None: self.send_telemetry_event( "vibe.user_rating_feedback", diff --git a/vibe/core/telemetry/types.py b/vibe/core/telemetry/types.py new file mode 100644 index 0000000..a1d41ea --- /dev/null +++ b/vibe/core/telemetry/types.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + + +class ClientMetadata(BaseModel): + name: str + version: str + + +AgentEntrypoint = Literal["cli", "acp", "programmatic", "unknown"] + + +class EntrypointMetadata(BaseModel): + agent_entrypoint: AgentEntrypoint + agent_version: str + client_name: str + client_version: str + + +TelemetryCallType = Literal["main_call", "secondary_call"] + + +class TelemetryBaseMetadata(BaseModel): + agent_entrypoint: AgentEntrypoint | None = None + agent_version: str | None = None + client_name: str | None = None + client_version: str | None = None + session_id: str | None = None + parent_session_id: str | None = None + + +class TelemetryRequestMetadata(TelemetryBaseMetadata): + call_type: TelemetryCallType + call_source: str = "vibe_code" + message_id: str | None = None diff --git a/vibe/core/teleport/nuage.py b/vibe/core/teleport/nuage.py index 90b14d3..fa4eea8 100644 --- a/vibe/core/teleport/nuage.py +++ b/vibe/core/teleport/nuage.py @@ -47,8 +47,14 @@ class WorkflowConfig(BaseModel): agent: VibeAgent = Field(default_factory=VibeAgent) +class TextChunk(BaseModel): + type: str = "text" + text: str + + class WorkflowParams(BaseModel): prompt: str + message: list[TextChunk] | None = None config: WorkflowConfig = Field(default_factory=WorkflowConfig) integrations: WorkflowIntegrations = Field(default_factory=WorkflowIntegrations) @@ -150,7 +156,7 @@ class NuageClient: }, ) if not response.is_success: - error_msg = f"Nuage workflow trigger failed: {response.text}" + error_msg = f"Vibe Code workflow trigger failed: {response.text}" raise ServiceTeleportError(error_msg) result = WorkflowExecuteResponse.model_validate(response.json()) return result.execution_id diff --git a/vibe/core/teleport/teleport.py b/vibe/core/teleport/teleport.py index 8b2aeb3..dd3e652 100644 --- a/vibe/core/teleport/teleport.py +++ b/vibe/core/teleport/teleport.py @@ -18,6 +18,7 @@ from vibe.core.teleport.nuage import ( GitHubParams, NuageClient, TeleportSession, + TextChunk, VibeAgent, WorkflowConfig, WorkflowIntegrations, @@ -45,39 +46,39 @@ class TeleportService: def __init__( self, session_logger: SessionLogger, - nuage_base_url: str, - nuage_workflow_id: str, - nuage_api_key: str, + vibe_code_base_url: str, + vibe_code_workflow_id: str, + vibe_code_api_key: str, workdir: Path | None = None, *, - nuage_task_queue: str | None = None, + vibe_code_task_queue: str | None = None, vibe_config: VibeConfig | None = None, client: httpx.AsyncClient | None = None, timeout: float = 60.0, ) -> None: self._session_logger = session_logger - self._nuage_base_url = nuage_base_url - self._nuage_workflow_id = nuage_workflow_id - self._nuage_api_key = nuage_api_key - self._nuage_task_queue = nuage_task_queue - self._nuage_project_name = ( - vibe_config.nuage_project_name if vibe_config else "Vibe" + self._vibe_code_base_url = vibe_code_base_url + self._vibe_code_workflow_id = vibe_code_workflow_id + self._vibe_code_api_key = vibe_code_api_key + self._vibe_code_task_queue = vibe_code_task_queue + self._vibe_code_project_name = ( + vibe_config.vibe_code_project_name if vibe_config else None ) self._vibe_config = vibe_config self._git = GitRepository(workdir) self._client = client self._owns_client = client is None self._timeout = timeout - self._nuage: NuageClient | None = None + self._nuage_client_instance: NuageClient | None = None async def __aenter__(self) -> TeleportService: if self._client is None: self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout)) - self._nuage = NuageClient( - self._nuage_base_url, - self._nuage_api_key, - self._nuage_workflow_id, - task_queue=self._nuage_task_queue, + self._nuage_client_instance = NuageClient( + self._vibe_code_base_url, + self._vibe_code_api_key, + self._vibe_code_workflow_id, + task_queue=self._vibe_code_task_queue, client=self._client, ) await self._git.__aenter__() @@ -103,15 +104,15 @@ class TeleportService: @property def _nuage_client(self) -> NuageClient: - if self._nuage is None: - self._nuage = NuageClient( - self._nuage_base_url, - self._nuage_api_key, - self._nuage_workflow_id, - task_queue=self._nuage_task_queue, + if self._nuage_client_instance is None: + self._nuage_client_instance = NuageClient( + self._vibe_code_base_url, + self._vibe_code_api_key, + self._vibe_code_workflow_id, + task_queue=self._vibe_code_task_queue, client=self._http_client, ) - return self._nuage + return self._nuage_client_instance async def check_supported(self) -> None: await self._git.get_info() @@ -162,6 +163,7 @@ class TeleportService: execution_id = await self._nuage_client.start_workflow( WorkflowParams( prompt=prompt, + message=[TextChunk(text=lechat_user_message)], config=WorkflowConfig( agent=VibeAgent( vibe_config=self._vibe_config.model_dump() @@ -175,7 +177,7 @@ class TeleportService: chat_assistant=ChatAssistantParams( create_thread=True, user_message=lechat_user_message, - project_name=self._nuage_project_name, + project_name=self._vibe_code_project_name, ), ), ) @@ -203,9 +205,9 @@ class TeleportService: raise ServiceTeleportError("Failed to push current branch to remote.") def _validate_config(self) -> None: - if not self._nuage_api_key: + if not self._vibe_code_api_key: env_var = ( - self._vibe_config.nuage_api_key_env_var + self._vibe_config.vibe_code_api_key_env_var if self._vibe_config else "MISTRAL_API_KEY" ) diff --git a/vibe/core/tools/base.py b/vibe/core/tools/base.py index 24fe0eb..e4098d4 100644 --- a/vibe/core/tools/base.py +++ b/vibe/core/tools/base.py @@ -31,14 +31,10 @@ from vibe.core.utils.io import read_safe if TYPE_CHECKING: from vibe.core.agents.manager import AgentManager from vibe.core.skills.manager import SkillManager + from vibe.core.telemetry.types import EntrypointMetadata from vibe.core.tools.mcp_sampling import MCPSamplingHandler from vibe.core.tools.permissions import PermissionContext - from vibe.core.types import ( - ApprovalCallback, - EntrypointMetadata, - SwitchAgentCallback, - UserInputCallback, - ) + from vibe.core.types import ApprovalCallback, SwitchAgentCallback, UserInputCallback ARGS_COUNT = 4 @@ -57,6 +53,7 @@ class InvokeContext: plan_file_path: Path | None = field(default=None) switch_agent_callback: SwitchAgentCallback | None = field(default=None) skill_manager: SkillManager | None = field(default=None) + scratchpad_dir: Path | None = field(default=None) class ToolError(Exception): diff --git a/vibe/core/tools/builtins/bash.py b/vibe/core/tools/builtins/bash.py index ebd1e7a..b31243e 100644 --- a/vibe/core/tools/builtins/bash.py +++ b/vibe/core/tools/builtins/bash.py @@ -5,7 +5,6 @@ from collections.abc import AsyncGenerator from functools import lru_cache import os from pathlib import Path -import signal import sys from typing import ClassVar, Literal, final @@ -13,6 +12,7 @@ from pydantic import BaseModel, Field from tree_sitter import Language, Node, Parser import tree_sitter_bash as tsbash +from vibe.core.scratchpad import is_scratchpad_path from vibe.core.tools.arity import build_session_pattern from vibe.core.tools.base import ( BaseTool, @@ -30,7 +30,7 @@ from vibe.core.tools.permissions import ( from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData from vibe.core.tools.utils import is_path_within_workdir from vibe.core.types import ToolResultEvent, ToolStreamEvent -from vibe.core.utils import is_windows +from vibe.core.utils import is_windows, kill_async_subprocess @lru_cache(maxsize=1) @@ -96,33 +96,6 @@ def _get_base_env() -> dict[str, str]: return base_env -async def _kill_process_tree(proc: asyncio.subprocess.Process) -> None: - if proc.returncode is not None: - return - - try: - if sys.platform == "win32": - try: - subprocess_proc = await asyncio.create_subprocess_exec( - "taskkill", - "/F", - "/T", - "/PID", - str(proc.pid), - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL, - ) - await subprocess_proc.wait() - except (FileNotFoundError, OSError): - proc.terminate() - else: - os.killpg(os.getpgid(proc.pid), signal.SIGKILL) - - await proc.wait() - except (ProcessLookupError, PermissionError, OSError): - pass - - def _get_default_allowlist() -> list[str]: common = ["cd", "echo", "git diff", "git log", "git status", "tree", "whoami"] @@ -132,6 +105,7 @@ def _get_default_allowlist() -> list[str]: return common + [ "cat", "file", + "find", "head", "ls", "pwd", @@ -190,6 +164,8 @@ _PATH_COMMANDS = { "wc", } +_FIND_EXECUTION_PREDICATES = {"-exec", "-execdir", "-ok", "-okdir"} + def _collect_outside_dirs(command_parts: list[str]) -> set[str]: """Collect parent directories referenced outside the workdir. @@ -224,6 +200,8 @@ def _collect_outside_dirs(command_parts: list[str]) -> set[str]: continue if is_path_within_workdir(token): continue + if is_scratchpad_path(token): + continue # Resolve relative / home-relative paths, then collect parent dir resolved = Path(token).expanduser() if not resolved.is_absolute(): @@ -235,6 +213,10 @@ def _collect_outside_dirs(command_parts: list[str]) -> set[str]: return dirs +def _matches_pattern(command: str, pattern: str) -> bool: + return command == pattern or command.startswith(pattern + " ") + + class BashToolConfig(BaseToolConfig): permission: ToolPermission = ToolPermission.ASK max_output_bytes: int = Field( @@ -298,70 +280,112 @@ class Bash( def get_status_text(cls) -> str: return "Running command" - def resolve_permission(self, args: BashArgs) -> PermissionContext | None: # noqa: PLR0911, PLR0912 - if is_windows(): - return None - - command_parts = _extract_commands(args.command) - if not command_parts: - return None - - def _matches_pattern(command: str, pattern: str) -> bool: - return command == pattern or command.startswith(pattern + " ") - - def find_denylist_match(command: str) -> str | None: - return next( - (p for p in self.config.denylist if _matches_pattern(command, p)), None - ) - - def is_standalone_denylisted(command: str) -> bool: - parts = command.split() - if not parts: - return False - base_command = parts[0] - if len(parts) == 1: - command_name = os.path.basename(base_command) - if command_name in self.config.denylist_standalone: - return True - if base_command in self.config.denylist_standalone: - return True + @staticmethod + def _has_find_execution_predicate(command: str) -> bool: + """Defensive check for find -exec, -execdir, -ok, -okdir predicates.""" + if not _matches_pattern(command, "find"): return False + return any(predicate in command for predicate in _FIND_EXECUTION_PREDICATES) - def is_allowlisted(command: str) -> bool: - return any( - _matches_pattern(command, pattern) for pattern in self.config.allowlist - ) + @staticmethod + def _build_command_required_permission( + invocation_pattern: str, session_pattern: str, label: str + ) -> RequiredPermission: + return RequiredPermission( + scope=PermissionScope.COMMAND_PATTERN, + invocation_pattern=invocation_pattern, + session_pattern=session_pattern, + label=label, + ) - def is_sensitive(command: str) -> bool: - tokens = command.split() - if not tokens: - return False - return tokens[0] in self.config.sensitive_patterns + @staticmethod + def _build_outside_directory_permission(glob: str) -> RequiredPermission: + return RequiredPermission( + scope=PermissionScope.OUTSIDE_DIRECTORY, + invocation_pattern=glob, + session_pattern=glob, + label=f"outside workdir ({glob})", + ) + + def _find_denylist_match(self, command: str) -> str | None: + return next( + (p for p in self.config.denylist if _matches_pattern(command, p)), None + ) + + def _is_standalone_denylisted(self, command: str) -> bool: + parts = command.split() + if not parts: + return False + base_command = parts[0] + if len(parts) == 1: + command_name = os.path.basename(base_command) + if command_name in self.config.denylist_standalone: + return True + if base_command in self.config.denylist_standalone: + return True + return False + + def _is_allowlisted(self, command: str) -> bool: + return any( + _matches_pattern(command, pattern) for pattern in self.config.allowlist + ) + + def _is_sensitive(self, command: str) -> bool: + tokens = command.split() + if not tokens: + return False + return tokens[0] in self.config.sensitive_patterns + + def _resolve_guardrail_permission( + self, command_parts: list[str] + ) -> PermissionContext | None: + find_execution_required: list[RequiredPermission] = [] + seen_find_execution: set[str] = set() for part in command_parts: - if matched := find_denylist_match(part): + if matched := self._find_denylist_match(part): return PermissionContext( permission=ToolPermission.NEVER, reason=f"Command denied: '{part}' matches denylist pattern '{matched}'. Do not attempt to run this command.", ) - if is_standalone_denylisted(part): + if self._is_standalone_denylisted(part): return PermissionContext( permission=ToolPermission.NEVER, reason=f"Command denied: '{part}' is not allowed as a standalone command. Do not attempt to run this command.", ) + if not self._has_find_execution_predicate(part): + continue + if part in seen_find_execution: + continue + seen_find_execution.add(part) + find_execution_required.append( + self._build_command_required_permission( + invocation_pattern=part, session_pattern=part, label=part + ) + ) + + if not find_execution_required: + return None + return PermissionContext( + permission=ToolPermission.ASK, required_permissions=find_execution_required + ) + + def _is_unconditionally_allowed( + self, command_parts: list[str], outside_dirs: set[str] + ) -> bool: + if any(self._is_sensitive(part) for part in command_parts): + return False if self.config.permission == ToolPermission.ALWAYS: - return PermissionContext(permission=ToolPermission.ALWAYS) + return True - has_sensitive = any(is_sensitive(part) for part in command_parts) - all_allowlisted = not has_sensitive and all( - is_allowlisted(part) for part in command_parts + return all(self._is_allowlisted(part) for part in command_parts) and ( + not outside_dirs ) - outside_dirs = _collect_outside_dirs(command_parts) - - if all_allowlisted and not outside_dirs: - return PermissionContext(permission=ToolPermission.ALWAYS) + def _build_required_permissions( + self, command_parts: list[str], outside_dirs: set[str] + ) -> list[RequiredPermission]: required: list[RequiredPermission] = [] seen_session: set[str] = set() @@ -371,43 +395,60 @@ class Bash( tokens = part.split() if not tokens: continue - if not is_sensitive(part) and is_allowlisted(part): + + is_sensitive = self._is_sensitive(part) + if not is_sensitive and self._is_allowlisted(part): continue - if is_sensitive(part): + if is_sensitive: required.append( - RequiredPermission( - scope=PermissionScope.COMMAND_PATTERN, - invocation_pattern=part, - session_pattern=part, - label=part, + self._build_command_required_permission( + invocation_pattern=part, session_pattern=part, label=part ) ) - else: - session_pat = build_session_pattern(tokens) - if session_pat not in seen_session: - seen_session.add(session_pat) - required.append( - RequiredPermission( - scope=PermissionScope.COMMAND_PATTERN, - invocation_pattern=part, - session_pattern=session_pat, - label=session_pat, - ) - ) + continue - if outside_dirs: - globs = sorted(str(Path(d) / "*") for d in outside_dirs) - for glob in globs: - required.append( - RequiredPermission( - scope=PermissionScope.OUTSIDE_DIRECTORY, - invocation_pattern=glob, - session_pattern=glob, - label=f"outside workdir ({glob})", - ) + session_pat = build_session_pattern(tokens) + if session_pat in seen_session: + continue + seen_session.add(session_pat) + required.append( + self._build_command_required_permission( + invocation_pattern=part, + session_pattern=session_pat, + label=session_pat, ) + ) + for glob in sorted(str(Path(d) / "*") for d in outside_dirs): + required.append(self._build_outside_directory_permission(glob)) + + return required + + def resolve_permission(self, args: BashArgs) -> PermissionContext | None: + if is_windows(): + return None + + command_parts = _extract_commands(args.command) + if not command_parts: + return None + + guardrail_permission = self._resolve_guardrail_permission(command_parts) + if ( + guardrail_permission + and guardrail_permission.permission == ToolPermission.NEVER + ): + return guardrail_permission + outside_dirs = _collect_outside_dirs(command_parts) + if ( + self._is_unconditionally_allowed(command_parts, outside_dirs) + and not guardrail_permission + ): + return PermissionContext(permission=ToolPermission.ALWAYS) + + required = self._build_required_permissions(command_parts, outside_dirs) + if guardrail_permission: + required.extend(guardrail_permission.required_permissions) if not required: return None @@ -464,7 +505,7 @@ class Bash( proc.communicate(), timeout=timeout ) except TimeoutError: - await _kill_process_tree(proc) + await kill_async_subprocess(proc) raise self._build_timeout_error(args.command, timeout) encoding = _get_subprocess_encoding() @@ -494,4 +535,4 @@ class Bash( raise ToolError(f"Error running command {args.command!r}: {exc}") from exc finally: if proc is not None: - await _kill_process_tree(proc) + await kill_async_subprocess(proc) diff --git a/vibe/core/tools/builtins/grep.py b/vibe/core/tools/builtins/grep.py index d041451..bf927e6 100644 --- a/vibe/core/tools/builtins/grep.py +++ b/vibe/core/tools/builtins/grep.py @@ -21,6 +21,7 @@ from vibe.core.tools.permissions import PermissionContext from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData from vibe.core.tools.utils import resolve_file_tool_permission from vibe.core.types import ToolStreamEvent +from vibe.core.utils import kill_async_subprocess from vibe.core.utils.io import read_safe if TYPE_CHECKING: @@ -242,8 +243,7 @@ class Grep( proc.communicate(), timeout=self.config.default_timeout ) except TimeoutError: - proc.kill() - await proc.wait() + await kill_async_subprocess(proc, kill_process_group=False) raise ToolError( f"Search timed out after {self.config.default_timeout}s" ) diff --git a/vibe/core/tools/builtins/read_file.py b/vibe/core/tools/builtins/read_file.py index 483515a..414b377 100644 --- a/vibe/core/tools/builtins/read_file.py +++ b/vibe/core/tools/builtins/read_file.py @@ -8,6 +8,7 @@ import anyio from pydantic import BaseModel, Field from vibe.core.config.harness_files import get_harness_files_manager +from vibe.core.scratchpad import is_scratchpad_path from vibe.core.tools.base import ( BaseTool, BaseToolConfig, @@ -193,6 +194,7 @@ class ReadFile( @classmethod def format_call_display(cls, args: ReadFileArgs) -> ToolCallDisplay: + tag = " (scratchpad)" if is_scratchpad_path(args.path) else "" summary = f"Reading {args.path}" if args.offset > 0 or args.limit is not None: parts = [] @@ -201,7 +203,7 @@ class ReadFile( if args.limit is not None: parts.append(f"limit {args.limit} lines") summary += f" ({', '.join(parts)})" - return ToolCallDisplay(summary=summary) + return ToolCallDisplay(summary=f"{summary}{tag}") @classmethod def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: @@ -211,7 +213,8 @@ class ReadFile( ) path_obj = Path(event.result.path) - message = f"Read {event.result.lines_read} line{'' if event.result.lines_read <= 1 else 's'} from {path_obj.name}" + tag = " (scratchpad)" if is_scratchpad_path(event.result.path) else "" + message = f"Read {event.result.lines_read} line{'' if event.result.lines_read <= 1 else 's'} from {path_obj.name}{tag}" if event.result.was_truncated: message += " (truncated)" diff --git a/vibe/core/tools/builtins/search_replace.py b/vibe/core/tools/builtins/search_replace.py index 0032324..c264053 100644 --- a/vibe/core/tools/builtins/search_replace.py +++ b/vibe/core/tools/builtins/search_replace.py @@ -11,6 +11,7 @@ import anyio from pydantic import BaseModel, Field from vibe.core.rewind.manager import FileSnapshot +from vibe.core.scratchpad import is_scratchpad_path from vibe.core.tools.base import ( BaseTool, BaseToolConfig, @@ -90,9 +91,10 @@ class SearchReplace( @classmethod def format_call_display(cls, args: SearchReplaceArgs) -> ToolCallDisplay: + tag = " (scratchpad)" if is_scratchpad_path(args.file_path) else "" blocks = cls._parse_search_replace_blocks(args.content) return ToolCallDisplay( - summary=f"Patching {args.file_path} ({len(blocks)} blocks)", + summary=f"Patching {args.file_path} ({len(blocks)} blocks){tag}", content=args.content, ) @@ -100,9 +102,10 @@ class SearchReplace( def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: if isinstance(event.result, SearchReplaceResult): path_name = Path(event.result.file).name + tag = " (scratchpad)" if is_scratchpad_path(event.result.file) else "" return ToolResultDisplay( success=True, - message=f"Applied {event.result.blocks_applied} block{'' if event.result.blocks_applied == 1 else 's'} to {path_name}", + message=f"Applied {event.result.blocks_applied} block{'' if event.result.blocks_applied == 1 else 's'} to {path_name}{tag}", warnings=event.result.warnings, ) diff --git a/vibe/core/tools/builtins/task.py b/vibe/core/tools/builtins/task.py index f880fe2..4df7335 100644 --- a/vibe/core/tools/builtins/task.py +++ b/vibe/core/tools/builtins/task.py @@ -141,10 +141,18 @@ class Task( if ctx and ctx.approval_callback: subagent_loop.set_approval_callback(ctx.approval_callback) + task_text = args.task + if ctx.scratchpad_dir: + task_text = ( + f"Scratchpad directory: {ctx.scratchpad_dir}\n" + "You can read and write files here without permission prompts.\n\n" + f"{args.task}" + ) + accumulated_response: list[str] = [] completed = True try: - async with aclosing(subagent_loop.act(args.task)) as events: + async with aclosing(subagent_loop.act(task_text)) as events: async for event in events: if isinstance(event, AssistantEvent) and event.content: accumulated_response.append(event.content) diff --git a/vibe/core/tools/builtins/write_file.py b/vibe/core/tools/builtins/write_file.py index e6a7c19..531407d 100644 --- a/vibe/core/tools/builtins/write_file.py +++ b/vibe/core/tools/builtins/write_file.py @@ -8,6 +8,7 @@ import anyio from pydantic import BaseModel, Field from vibe.core.rewind.manager import FileSnapshot +from vibe.core.scratchpad import is_scratchpad_path from vibe.core.tools.base import ( BaseTool, BaseToolConfig, @@ -57,17 +58,19 @@ class WriteFile( @classmethod def format_call_display(cls, args: WriteFileArgs) -> ToolCallDisplay: + tag = " (scratchpad)" if is_scratchpad_path(args.path) else "" + overwrite = " (overwrite)" if args.overwrite else "" return ToolCallDisplay( - summary=f"Writing {args.path}{' (overwrite)' if args.overwrite else ''}", - content=args.content, + summary=f"Writing {args.path}{overwrite}{tag}", content=args.content ) @classmethod def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay: if isinstance(event.result, WriteFileResult): action = "Overwritten" if event.result.file_existed else "Created" + tag = " (scratchpad)" if is_scratchpad_path(event.result.path) else "" return ToolResultDisplay( - success=True, message=f"{action} {Path(event.result.path).name}" + success=True, message=f"{action} {Path(event.result.path).name}{tag}" ) return ToolResultDisplay(success=True, message="File written") diff --git a/vibe/core/tools/connectors/connector_registry.py b/vibe/core/tools/connectors/connector_registry.py index 6d92166..ca5582b 100644 --- a/vibe/core/tools/connectors/connector_registry.py +++ b/vibe/core/tools/connectors/connector_registry.py @@ -35,7 +35,7 @@ if TYPE_CHECKING: _LIST_PAGE_SIZE = 100 _LIST_QUERY_FILTERS: ConnectorsQueryFiltersTypedDict = {"active": True} -_TOOL_FETCH_TIMEOUT = 5.0 +_TOOL_FETCH_TIMEOUT = 8.0 def _normalize_name(name: str) -> str: diff --git a/vibe/core/tools/manager.py b/vibe/core/tools/manager.py index 50cb397..bc48483 100644 --- a/vibe/core/tools/manager.py +++ b/vibe/core/tools/manager.py @@ -197,19 +197,76 @@ class ToolManager: name: cls for name, cls in self._available.items() if cls.is_available() } + # Per-source filtering first (MCP server/connector disabled flags). + result = self._apply_per_source_filtering(runtime_available) + + # Global overrides take precedence. if self._config.enabled_tools: return { name: cls - for name, cls in runtime_available.items() + for name, cls in result.items() if name_matches(name, self._config.enabled_tools) } if self._config.disabled_tools: return { name: cls - for name, cls in runtime_available.items() + for name, cls in result.items() if not name_matches(name, self._config.disabled_tools) } - return runtime_available + return result + + def _apply_per_source_filtering( + self, tools: dict[str, type[BaseTool]] + ) -> dict[str, type[BaseTool]]: + """Filter out MCP/connector tools disabled at the server or connector level.""" + disabled_sources, per_source_disabled = self._build_source_disable_index() + if not disabled_sources and not per_source_disabled: + return tools + + return { + name: cls + for name, cls in tools.items() + if not self._is_source_disabled(cls, disabled_sources, per_source_disabled) + } + + def _build_source_disable_index( + self, + ) -> tuple[set[tuple[str, bool]], dict[tuple[str, bool], set[str]]]: + """Return (fully_disabled, per_tool_disabled) keyed by (source_name, is_connector).""" + disabled_sources: set[tuple[str, bool]] = set() + per_source_disabled: dict[tuple[str, bool], set[str]] = {} + + for srv in self._config.mcp_servers: + key = (srv.name, False) + if srv.disabled: + disabled_sources.add(key) + elif srv.disabled_tools: + per_source_disabled[key] = set(srv.disabled_tools) + + for cfg in self._config.connectors: + key = (cfg.name, True) + if cfg.disabled: + disabled_sources.add(key) + elif cfg.disabled_tools: + per_source_disabled[key] = set(cfg.disabled_tools) + + return disabled_sources, per_source_disabled + + @staticmethod + def _is_source_disabled( + cls: type[BaseTool], + disabled_sources: set[tuple[str, bool]], + per_source_disabled: dict[tuple[str, bool], set[str]], + ) -> bool: + if not issubclass(cls, MCPTool): + return False + server_name = cls.get_server_name() + if server_name is None: + return False + key = (server_name, cls.is_connector()) + if key in disabled_sources: + return True + return cls.get_remote_name() in per_source_disabled.get(key, set()) def integrate_mcp(self, *, raise_on_failure: bool = False) -> None: """Discover and register MCP tools (sync wrapper). diff --git a/vibe/core/tools/mcp_sampling.py b/vibe/core/tools/mcp_sampling.py index 5adaef6..6679698 100644 --- a/vibe/core/tools/mcp_sampling.py +++ b/vibe/core/tools/mcp_sampling.py @@ -24,9 +24,13 @@ class MCPSamplingHandler: self, backend_getter: Callable[[], BackendLike], config_getter: Callable[[], Any], + metadata_getter: Callable[[], dict[str, str] | None] | None = None, + extra_headers_getter: Callable[[], dict[str, str] | None] | None = None, ) -> None: self._backend_getter = backend_getter self._config_getter = config_getter + self._metadata_getter = metadata_getter + self._extra_headers_getter = extra_headers_getter async def __call__( self, @@ -53,7 +57,14 @@ class MCPSamplingHandler: tools=None, max_tokens=params.maxTokens, tool_choice=None, - extra_headers=None, + extra_headers=( + None + if self._extra_headers_getter is None + else self._extra_headers_getter() + ), + metadata=( + None if self._metadata_getter is None else self._metadata_getter() + ), ) content_text = result.message.content or "" diff --git a/vibe/core/tools/mcp_settings.py b/vibe/core/tools/mcp_settings.py new file mode 100644 index 0000000..59f6b57 --- /dev/null +++ b/vibe/core/tools/mcp_settings.py @@ -0,0 +1,75 @@ +"""Persist MCP server and connector enable/disable settings. + +Shared by all entrypoints (CLI, ACP, etc.) so toggle logic is not +tied to a particular UI layer. +""" + +from __future__ import annotations + +from typing import Any + +from vibe.core.config import VibeConfig + + +def updated_tool_list(tools: list[str], name: str, disabled: bool) -> list[str]: + """Return a new disabled_tools list with *name* added or removed (unique).""" + if disabled: + return list(dict.fromkeys([*tools, name])) + return [t for t in tools if t != name] + + +def persist_mcp_toggle( + config: VibeConfig, + *, + name: str, + is_connector: bool, + disabled: bool, + tool_name: str | None = None, +) -> None: + """Save an MCP server/connector or individual tool toggle to the config file.""" + if is_connector: + _persist_connector_toggle(name=name, disabled=disabled, tool_name=tool_name) + else: + _persist_server_toggle(name=name, disabled=disabled, tool_name=tool_name) + + +def _persist_server_toggle(*, name: str, disabled: bool, tool_name: str | None) -> None: + persisted = VibeConfig.get_persisted_config() + servers: list[dict[str, Any]] = list(persisted.get("mcp_servers", [])) + for s in servers: + if s.get("name") == name: + if tool_name is not None: + s["disabled_tools"] = updated_tool_list( + s.get("disabled_tools", []), tool_name, disabled + ) + else: + s["disabled"] = disabled + break + else: + # Server not in base config (profile-only) -- nothing to persist. + return + VibeConfig.save_updates({"mcp_servers": servers}) + + +def _persist_connector_toggle( + *, name: str, disabled: bool, tool_name: str | None +) -> None: + persisted = VibeConfig.get_persisted_config() + connectors: list[dict[str, Any]] = list(persisted.get("connectors", [])) + for c in connectors: + if c.get("name") == name: + if tool_name is not None: + c["disabled_tools"] = updated_tool_list( + c.get("disabled_tools", []), tool_name, disabled + ) + else: + c["disabled"] = disabled + break + else: + entry: dict[str, Any] = {"name": name} + if tool_name is not None: + entry["disabled_tools"] = [tool_name] if disabled else [] + else: + entry["disabled"] = disabled + connectors.append(entry) + VibeConfig.save_updates({"connectors": connectors}) diff --git a/vibe/core/tools/utils.py b/vibe/core/tools/utils.py index 2373e27..fb4425a 100644 --- a/vibe/core/tools/utils.py +++ b/vibe/core/tools/utils.py @@ -3,6 +3,7 @@ from __future__ import annotations import fnmatch from pathlib import Path, PurePath +from vibe.core.scratchpad import is_scratchpad_path from vibe.core.tools.base import ToolPermission from vibe.core.tools.permissions import ( PermissionContext, @@ -70,9 +71,12 @@ def resolve_file_tool_permission( ) -> PermissionContext | None: """Resolve permission for a file-based tool invocation. - Checks allowlist/denylist, then sensitive patterns, then workdir boundary. + Checks scratchpad, then allowlist/denylist, then sensitive patterns, then workdir boundary. Returns PermissionContext with granular required_permissions when applicable. """ + if is_scratchpad_path(path_str): + return PermissionContext(permission=ToolPermission.ALWAYS) + if ( result := resolve_path_permission( path_str, allowlist=allowlist, denylist=denylist diff --git a/vibe/core/trusted_folders.py b/vibe/core/trusted_folders.py index 40180d6..9146ac2 100644 --- a/vibe/core/trusted_folders.py +++ b/vibe/core/trusted_folders.py @@ -37,8 +37,12 @@ class TrustedFoldersManager: self._file_path = TRUSTED_FOLDERS_FILE.path self._trusted: list[str] = [] self._untrusted: list[str] = [] + self._session_trusted: list[str] = [] self._load() + def trust_for_session(self, path: Path) -> None: + self._session_trusted.append(self._normalize_path(path)) + def _normalize_path(self, path: Path) -> str: return str(path.expanduser().resolve()) @@ -71,13 +75,14 @@ class TrustedFoldersManager: def is_trusted(self, path: Path) -> bool | None: """Check trust walking up from *path* to filesystem root. - The first ancestor (or *path* itself) found in either the trusted - or untrusted list wins. Returns ``None`` when no decision exists. + The first ancestor (or *path* itself) found in either the trusted, + session-trusted, or untrusted list wins. Returns ``None`` when no + decision exists. """ current = Path(self._normalize_path(path)) while True: s = str(current) - if s in self._trusted: + if s in self._trusted or s in self._session_trusted: return True if s in self._untrusted: return False @@ -88,11 +93,11 @@ class TrustedFoldersManager: return None def find_trust_root(self, path: Path) -> Path | None: - """Return the closest ancestor (or *path* itself) explicitly in the trusted list.""" + """Return the closest ancestor (or *path* itself) explicitly trusted.""" current = Path(self._normalize_path(path)) while True: s = str(current) - if s in self._trusted: + if s in self._trusted or s in self._session_trusted: return current parent = current.parent if parent == current: diff --git a/vibe/core/types.py b/vibe/core/types.py index a1a7a03..c14c6fa 100644 --- a/vibe/core/types.py +++ b/vibe/core/types.py @@ -135,6 +135,7 @@ class SessionInfo(BaseModel): class SessionMetadata(BaseModel): session_id: str + parent_session_id: str | None = None start_time: str end_time: str | None git_commit: str | None @@ -143,18 +144,6 @@ class SessionMetadata(BaseModel): username: str -class ClientMetadata(BaseModel): - name: str - version: str - - -class EntrypointMetadata(BaseModel): - agent_entrypoint: Literal["cli", "acp", "programmatic"] - agent_version: str - client_name: str - client_version: str - - StrToolChoice = Literal["auto", "none", "any", "required"] @@ -217,6 +206,7 @@ class LLMMessage(BaseModel): content: Content | None = None injected: bool = False reasoning_content: Content | None = None + reasoning_state: list[str] | None = None reasoning_signature: str | None = None reasoning_message_id: str | None = None tool_calls: list[ToolCall] | None = None @@ -241,6 +231,7 @@ class LLMMessage(BaseModel): "role": role, "content": getattr(v, "content", ""), "reasoning_content": reasoning_content, + "reasoning_state": getattr(v, "reasoning_state", None), "reasoning_signature": getattr(v, "reasoning_signature", None), "reasoning_message_id": getattr(v, "reasoning_message_id", None) or (str(uuid4()) if reasoning_content else None), @@ -278,6 +269,13 @@ class LLMMessage(BaseModel): if not reasoning_signature: reasoning_signature = None + reasoning_state: list[str] | None = None + if self.reasoning_state or other.reasoning_state: + reasoning_state = [ + *(self.reasoning_state or []), + *(other.reasoning_state or []), + ] + tool_calls_map = OrderedDict[int, ToolCall]() for tool_calls in [self.tool_calls or [], other.tool_calls or []]: for tc in tool_calls: @@ -303,6 +301,7 @@ class LLMMessage(BaseModel): role=self.role, content=content, reasoning_content=reasoning_content, + reasoning_state=reasoning_state, reasoning_signature=reasoning_signature, reasoning_message_id=self.reasoning_message_id or other.reasoning_message_id, @@ -533,3 +532,13 @@ class RateLimitError(Exception): super().__init__( "Rate limits exceeded. Please wait a moment before trying again." ) + + +class ContextTooLongError(Exception): + def __init__(self, provider: str, model: str) -> None: + self.provider = provider + self.model = model + super().__init__( + "The conversation context exceeds the model's maximum limit. " + "Use /rewind to undo recent actions, then /compact to summarize the conversation." + ) diff --git a/vibe/core/utils/__init__.py b/vibe/core/utils/__init__.py index 67a0e7b..51f86f1 100644 --- a/vibe/core/utils/__init__.py +++ b/vibe/core/utils/__init__.py @@ -6,6 +6,7 @@ vibe.core.utils.slug when needed to avoid circular imports with config. from __future__ import annotations +from vibe.core.utils.async_subprocess import kill_async_subprocess from vibe.core.utils.concurrency import ( AsyncExecutor, ConversationLimitException, @@ -52,6 +53,7 @@ __all__ = [ "is_dangerous_directory", "is_user_cancellation_event", "is_windows", + "kill_async_subprocess", "name_matches", "run_sync", "utc_now", diff --git a/vibe/core/utils/async_subprocess.py b/vibe/core/utils/async_subprocess.py new file mode 100644 index 0000000..401e351 --- /dev/null +++ b/vibe/core/utils/async_subprocess.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import asyncio +import logging +import os +import signal + +from vibe.core.utils.platform import is_windows + +logger = logging.getLogger(__name__) + + +async def kill_async_subprocess( + proc: asyncio.subprocess.Process, *, kill_process_group: bool = True +) -> None: + """Force-terminate an asyncio child process and wait until it exits. + + With ``kill_process_group=True`` (default), on Unix this sends ``SIGKILL`` + to the child's process group via :func:`os.killpg`. Only use that mode + when the child is isolated in its own group (for example + ``start_new_session=True`` with ``create_subprocess_shell``); otherwise the + group id may match the parent's and unrelated processes could be killed. + + On Windows, ``kill_process_group=True`` runs ``taskkill /F /T`` to kill the + process tree. + + With ``kill_process_group=False``, :meth:`asyncio.subprocess.Process.kill` + is used on all platforms (typical for a single ``create_subprocess_exec`` + leaf such as ``grep``). + """ + if proc.returncode is not None: + return + + try: + if not kill_process_group: + proc.kill() + elif is_windows(): + try: + subprocess_proc = await asyncio.create_subprocess_exec( + "taskkill", + "/F", + "/T", + "/PID", + str(proc.pid), + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await subprocess_proc.wait() + except (FileNotFoundError, OSError): + proc.terminate() + else: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + except Exception: + logger.debug( + "Unexpected error killing process group for pid %s", + proc.pid, + exc_info=True, + ) + + await proc.wait() + except (ProcessLookupError, PermissionError, OSError): + pass diff --git a/vibe/setup/onboarding/__init__.py b/vibe/setup/onboarding/__init__.py index d8b8795..30fa7c4 100644 --- a/vibe/setup/onboarding/__init__.py +++ b/vibe/setup/onboarding/__init__.py @@ -1,27 +1,39 @@ from __future__ import annotations import sys +from typing import Any from rich import print as rprint from textual.app import App from vibe.core.paths import GLOBAL_ENV_FILE +from vibe.core.telemetry.types import EntrypointMetadata from vibe.setup.onboarding.screens import ApiKeyScreen, WelcomeScreen class OnboardingApp(App[str | None]): CSS_PATH = "onboarding.tcss" + def __init__( + self, entrypoint_metadata: EntrypointMetadata | None = None, **kwargs: Any + ) -> None: + super().__init__(**kwargs) + self._entrypoint_metadata = entrypoint_metadata + def on_mount(self) -> None: self.theme = "textual-ansi" self.install_screen(WelcomeScreen(), "welcome") - self.install_screen(ApiKeyScreen(), "api_key") + self.install_screen( + ApiKeyScreen(entrypoint_metadata=self._entrypoint_metadata), "api_key" + ) self.push_screen("welcome") -def run_onboarding(app: App | None = None) -> None: - result = (app or OnboardingApp()).run() +def run_onboarding( + app: App | None = None, *, entrypoint_metadata: EntrypointMetadata | None = None +) -> None: + result = (app or OnboardingApp(entrypoint_metadata=entrypoint_metadata)).run() match result: case None: rprint("\n[yellow]Setup cancelled. See you next time![/]") diff --git a/vibe/setup/onboarding/context.py b/vibe/setup/onboarding/context.py index 6828e51..4341965 100644 --- a/vibe/setup/onboarding/context.py +++ b/vibe/setup/onboarding/context.py @@ -188,7 +188,7 @@ class OnboardingContext: @classmethod def from_config(cls, config: VibeConfig) -> OnboardingContext: - return cls(provider=config.get_provider_for_model(config.get_active_model())) + return cls(provider=config.get_active_provider()) @classmethod def load(cls, **overrides: Any) -> OnboardingContext: diff --git a/vibe/setup/onboarding/screens/api_key.py b/vibe/setup/onboarding/screens/api_key.py index 8e1d8e1..b02a405 100644 --- a/vibe/setup/onboarding/screens/api_key.py +++ b/vibe/setup/onboarding/screens/api_key.py @@ -16,6 +16,7 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.config import DEFAULT_PROVIDERS, ProviderConfig, VibeConfig from vibe.core.paths import GLOBAL_ENV_FILE from vibe.core.telemetry.send import TelemetryClient +from vibe.core.telemetry.types import EntrypointMetadata from vibe.core.types import Backend from vibe.setup.onboarding.base import OnboardingScreen from vibe.setup.onboarding.context import OnboardingContext @@ -33,7 +34,12 @@ def _save_api_key_to_env_file(env_key: str, api_key: str) -> None: set_key(GLOBAL_ENV_FILE.path, env_key, api_key) -def persist_api_key(provider: ProviderConfig, api_key: str) -> str: +def persist_api_key( + provider: ProviderConfig, + api_key: str, + *, + entrypoint_metadata: EntrypointMetadata | None = None, +) -> str: env_key = provider.api_key_env_var if not env_key: return "env_var_error:" @@ -47,7 +53,10 @@ def persist_api_key(provider: ProviderConfig, api_key: str) -> str: return f"save_error:{err}" if provider.backend == Backend.MISTRAL: try: - telemetry = TelemetryClient(config_getter=VibeConfig) + telemetry = TelemetryClient( + config_getter=VibeConfig, + entrypoint_metadata_getter=lambda: entrypoint_metadata, + ) telemetry.send_onboarding_api_key_added() except Exception: pass @@ -77,9 +86,15 @@ class ApiKeyScreen(OnboardingScreen): NEXT_SCREEN = None - def __init__(self, provider: ProviderConfig | None = None) -> None: + def __init__( + self, + provider: ProviderConfig | None = None, + *, + entrypoint_metadata: EntrypointMetadata | None = None, + ) -> None: super().__init__() self.provider = _resolve_onboarding_provider(provider) + self._entrypoint_metadata = entrypoint_metadata def _compose_provider_link(self, provider_name: str) -> ComposeResult: if self.provider.name not in PROVIDER_HELP: @@ -159,7 +174,11 @@ class ApiKeyScreen(OnboardingScreen): self._save_and_finish(event.value) def _save_and_finish(self, api_key: str) -> None: - self.app.exit(persist_api_key(self.provider, api_key)) + self.app.exit( + persist_api_key( + self.provider, api_key, entrypoint_metadata=self._entrypoint_metadata + ) + ) def on_mouse_up(self, event: MouseUp) -> None: copy_selection_to_clipboard(self.app) diff --git a/vibe/whats_new.md b/vibe/whats_new.md index 5a01b33..b5906cf 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -1,3 +1,7 @@ -# What's new in v2.8.1 - -- **Builtin skills system**: Added self-awareness skill for enhanced functionality +# What's new in v2.9.0 +- **Scratchpad directory**: Model's autonomous workspace for temporary files, intermediate results, and drafts shared with subagents +- **`/copy` command**: New slash command to copy content directly from the conversation +- **`--trust` flag**: Grant session-only tool permissions +- **MCP toggle**: Enable/disable MCP servers and individual tools from the `/mcp` menu +- **Custom compaction**: Provide custom instructions when compacting context via `/compact` +- **Thinking level picker**: Choose thinking level with `/thinking`