From 702d0f412eebc213dafb956a6e3d90e3a2a5d35a Mon Sep 17 00:00:00 2001 From: Mathias Gesbert Date: Mon, 8 Jun 2026 14:59:41 +0200 Subject: [PATCH] v2.14.1 (#756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Clément Drouin Co-authored-by: Gauthier Guinet <43207538+Gguinet@users.noreply.github.com> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Pierre Rossinès Co-authored-by: Stanislas Co-authored-by: Mistral Vibe --- AGENTS.md | 2 +- CHANGELOG.md | 14 + README.md | 8 +- distribution/zed/extension.toml | 12 +- pyproject.toml | 2 +- tests/acp/test_commands.py | 335 +++++++++++++++++- tests/acp/test_initialize.py | 61 +++- tests/acp/test_proxy_setup_acp.py | 24 +- tests/acp/test_set_config_option.py | 129 +++++++ tests/cli/test_startup_update_prompt.py | 224 ++++++++++++ tests/conftest.py | 1 - tests/core/test_build_attachment_counts.py | 34 ++ tests/core/test_telemetry_send.py | 43 ++- tests/e2e/common.py | 1 - tests/setup/__init__.py | 0 tests/setup/test_update_prompt_dialog.py | 127 +++++++ ...shot_shows_release_update_notification.svg | 204 ----------- ...apshot_update_prompt_continue_selected.svg | 138 ++++++++ .../test_snapshot_update_prompt_initial.svg | 138 ++++++++ ...ui_snapshot_release_update_notification.py | 51 --- .../test_ui_snapshot_update_prompt.py | 42 +++ .../test_ui_update_notification.py | 111 +----- tests/update_notifier/test_update_use_case.py | 140 +++++++- uv.lock | 2 +- vibe/__init__.py | 2 +- vibe/acp/acp_agent_loop.py | 39 +- vibe/acp/commands/__init__.py | 4 +- vibe/acp/commands/registry.py | 34 +- vibe/acp/teleport.py | 334 +++++++++++++++++ vibe/acp/utils.py | 5 + vibe/cli/cli.py | 158 ++++++--- vibe/cli/textual_ui/app.py | 50 +-- vibe/cli/update_notifier/__init__.py | 4 + .../filesystem_update_cache_repository.py | 24 +- .../ports/update_cache_repository.py | 1 + vibe/cli/update_notifier/update.py | 64 +++- vibe/cli/update_notifier/whats_new.py | 11 +- vibe/core/agent_loop.py | 15 +- vibe/core/config/_settings.py | 1 - vibe/core/skills/builtins/vibe.py | 3 +- vibe/core/telemetry/build_metadata.py | 13 + vibe/core/telemetry/send.py | 7 + vibe/core/telemetry/types.py | 5 + vibe/core/tools/builtins/prompts/bash.md | 2 +- .../core/tools/builtins/prompts/write_file.md | 2 +- vibe/core/tools/builtins/write_file.py | 6 +- .../trusted_folders/trust_folder_dialog.py | 2 +- vibe/setup/update_prompt/__init__.py | 8 + .../update_prompt/update_prompt_dialog.py | 221 ++++++++++++ .../update_prompt/update_prompt_dialog.tcss | 85 +++++ vibe/whats_new.md | 5 +- 51 files changed, 2446 insertions(+), 502 deletions(-) create mode 100644 tests/cli/test_startup_update_prompt.py create mode 100644 tests/core/test_build_attachment_counts.py create mode 100644 tests/setup/__init__.py create mode 100644 tests/setup/test_update_prompt_dialog.py delete mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_update_prompt/test_snapshot_update_prompt_continue_selected.svg create mode 100644 tests/snapshots/__snapshots__/test_ui_snapshot_update_prompt/test_snapshot_update_prompt_initial.svg delete mode 100644 tests/snapshots/test_ui_snapshot_release_update_notification.py create mode 100644 tests/snapshots/test_ui_snapshot_update_prompt.py create mode 100644 vibe/acp/teleport.py create mode 100644 vibe/setup/update_prompt/__init__.py create mode 100644 vibe/setup/update_prompt/update_prompt_dialog.py create mode 100644 vibe/setup/update_prompt/update_prompt_dialog.tcss diff --git a/AGENTS.md b/AGENTS.md index 523a2fb..3eb593a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ Always go through `uv` — never invoke bare `python` or `pip`. - 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 +- Never call a private method from outside of its class in production code. Accessing private methods in tests is acceptable. - Avoid comments and docstrings, except for when there's a hard to spot corner case ## Typing & imports diff --git a/CHANGELOG.md b/CHANGELOG.md index dba0b66..772d81f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.14.1] - 2026-06-08 + +### Added + +- `/teleport` slash command exposed over ACP, mirroring the TUI command for IDE integrations +- Startup prompt to install a pending Vibe update before continuing the session + +### Fixed + +- JetBrains IDEs no longer trigger a preemptive auth prompt over ACP and no longer drop terminal arguments +- Initial ACP slash-command advertisement is delayed so Zed registers commands like `/help` instead of rejecting them +- Built-in tool prompts no longer reference the removed `search_replace` name and now point to `edit` + + ## [2.14.0] - 2026-06-04 ### Added diff --git a/README.md b/README.md index e9f3f94..26bf108 100644 --- a/README.md +++ b/README.md @@ -644,14 +644,12 @@ Each path is implicitly trusted (no trust prompt) and contributes its `AGENTS.md ### Update Settings -#### Auto-Update +Vibe checks PyPI at most once per day during a session. When a newer version is found, the next launch shows an update prompt before opening the chat, offering to either update immediately (via `uv tool upgrade mistral-vibe` or `brew upgrade mistral-vibe`) or continue with the current version. -Vibe includes an automatic update feature that keeps your installation current. This is enabled by default. - -To disable auto-updates, add this to your `config.toml`: +To disable the daily check entirely, add this to your `config.toml`: ```toml -enable_auto_update = false +enable_update_checks = false ``` ### Notification Settings diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index a110bf3..e66310a 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.14.0" +version = "2.14.1" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,21 +11,21 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.0/vibe-acp-darwin-aarch64-2.14.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.1/vibe-acp-darwin-aarch64-2.14.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.0/vibe-acp-darwin-x86_64-2.14.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.1/vibe-acp-darwin-x86_64-2.14.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.0/vibe-acp-linux-aarch64-2.14.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.1/vibe-acp-linux-aarch64-2.14.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.0/vibe-acp-linux-x86_64-2.14.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.1/vibe-acp-linux-x86_64-2.14.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.0/vibe-acp-windows-x86_64-2.14.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.1/vibe-acp-windows-x86_64-2.14.1.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index 7789d53..882832f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.14.0" +version = "2.14.1" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/acp/test_commands.py b/tests/acp/test_commands.py index 585421d..136a564 100644 --- a/tests/acp/test_commands.py +++ b/tests/acp/test_commands.py @@ -3,10 +3,21 @@ from __future__ import annotations import asyncio +from collections.abc import AsyncGenerator from pathlib import Path +from typing import cast from unittest.mock import AsyncMock, patch -from acp.schema import AgentMessageChunk, AvailableCommandsUpdate, TextContentBlock +from acp.schema import ( + AgentMessageChunk, + AllowedOutcome, + AvailableCommandsUpdate, + DeniedOutcome, + RequestPermissionResponse, + TextContentBlock, + ToolCallProgress, + ToolCallStart, +) import pytest from tests.acp.conftest import _create_acp_agent @@ -14,8 +25,20 @@ from tests.skills.conftest import create_skill from tests.stubs.fake_backend import FakeBackend from tests.stubs.fake_client import FakeClient from vibe.acp.acp_agent_loop import VibeAcpAgentLoop +from vibe.acp.teleport import TELEPORT_PUSH_OPTION_ID from vibe.core.agent_loop import AgentLoop from vibe.core.config import SessionLoggingConfig +from vibe.core.teleport.errors import ServiceTeleportError +from vibe.core.teleport.teleport import TeleportService +from vibe.core.teleport.types import ( + TeleportCheckingGitEvent, + TeleportCompleteEvent, + TeleportPushingEvent, + TeleportPushRequiredEvent, + TeleportPushResponseEvent, + TeleportStartingWorkflowEvent, +) +from vibe.core.types import LLMMessage, Role def _get_client(agent: VibeAcpAgentLoop) -> FakeClient: @@ -32,14 +55,37 @@ def _get_message_texts(agent: VibeAcpAgentLoop) -> list[str]: ] +def _get_tool_updates( + agent: VibeAcpAgentLoop, +) -> list[ToolCallStart | ToolCallProgress]: + return [ + u.update + for u in _get_client(agent)._session_updates + if isinstance(u.update, (ToolCallStart, ToolCallProgress)) + ] + + +def _set_teleport_service(agent_loop: AgentLoop, service: object) -> None: + agent_loop._teleport_service = cast(TeleportService, service) + + async def _new_session_and_clear(agent: VibeAcpAgentLoop) -> str: """Create a new session, drain the startup updates, return session_id.""" resp = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) - await asyncio.sleep(0) # let background tasks (available_commands) complete + await _wait_for_available_commands(agent) _get_client(agent)._session_updates.clear() return resp.session_id +async def _wait_for_available_commands(agent: VibeAcpAgentLoop) -> None: + for _ in range(50): + updates = _get_client(agent)._session_updates + if any(isinstance(u.update, AvailableCommandsUpdate) for u in updates): + return + await asyncio.sleep(0.01) + raise TimeoutError("available commands update was not sent") + + async def _prompt(agent: VibeAcpAgentLoop, session_id: str, text: str): return await agent.prompt( prompt=[TextContentBlock(type="text", text=text)], session_id=session_id @@ -51,6 +97,7 @@ def _make_patched_agent_loop( *, skill_paths: list[Path] | None = None, session_logging: SessionLoggingConfig | None = None, + vibe_code_enabled: bool | None = None, ) -> type[AgentLoop]: """Create a PatchedAgentLoop class that injects config overrides.""" config_updates: dict = {} @@ -58,6 +105,8 @@ def _make_patched_agent_loop( config_updates["skill_paths"] = skill_paths if session_logging is not None: config_updates["session_logging"] = session_logging + if vibe_code_enabled is not None: + config_updates["vibe_code_enabled"] = vibe_code_enabled class PatchedAgentLoop(AgentLoop): def __init__(self, *args, **kwargs) -> None: @@ -75,6 +124,13 @@ def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop: return _create_acp_agent() +@pytest.fixture +def acp_agent_loop_vibe_code_disabled(backend: FakeBackend) -> VibeAcpAgentLoop: + patched = _make_patched_agent_loop(backend, vibe_code_enabled=False) + patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=patched).start() + return _create_acp_agent() + + @pytest.fixture def skills_dir(tmp_path: Path) -> Path: d = tmp_path / "skills" @@ -159,6 +215,264 @@ class TestHandleCompact: mock_compact.assert_called_once() +class TestHandleTeleport: + @pytest.mark.asyncio + async def test_available_commands_includes_teleport( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + await _wait_for_available_commands(acp_agent_loop) + + available = [ + u + for u in _get_client(acp_agent_loop)._session_updates + if isinstance(u.update, AvailableCommandsUpdate) + ] + cmd_names = [c.name for c in available[0].update.available_commands] + + assert "teleport" in cmd_names + + @pytest.mark.asyncio + async def test_teleport_hidden_when_vibe_code_disabled( + self, acp_agent_loop_vibe_code_disabled: VibeAcpAgentLoop + ) -> None: + agent = acp_agent_loop_vibe_code_disabled + await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + await _wait_for_available_commands(agent) + + available = [ + u + for u in _get_client(agent)._session_updates + if isinstance(u.update, AvailableCommandsUpdate) + ] + cmd_names = [c.name for c in available[0].update.available_commands] + + assert "teleport" not in cmd_names + + @pytest.mark.asyncio + async def test_teleport_without_history_replies_with_no_history( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_id = await _new_session_and_clear(acp_agent_loop) + + response = await _prompt(acp_agent_loop, session_id, "/teleport") + + assert response.stop_reason == "end_turn" + assert response.field_meta == { + "tool_name": "teleport", + "teleport": {"status": "no_history"}, + } + assert _get_message_texts(acp_agent_loop) == [ + "No conversation history to teleport." + ] + assert _get_tool_updates(acp_agent_loop) == [] + + @pytest.mark.asyncio + async def test_teleport_sends_tool_updates_and_structured_url( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + class FakeTeleportService: + prompts: list[str] + + def __init__(self) -> None: + self.prompts = [] + + async def __aenter__(self) -> FakeTeleportService: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def execute(self, prompt: str) -> AsyncGenerator[object, object]: + self.prompts.append(prompt) + yield TeleportCheckingGitEvent() + yield TeleportStartingWorkflowEvent() + yield TeleportCompleteEvent(url="https://chat.example.com/code/1/2") + + session_id = await _new_session_and_clear(acp_agent_loop) + session = acp_agent_loop.sessions[session_id] + session.agent_loop.messages.append( + LLMMessage(role=Role.user, content="continue this task") + ) + service = FakeTeleportService() + _set_teleport_service(session.agent_loop, service) + + response = await _prompt(acp_agent_loop, session_id, "/teleport ignored") + + assert response.stop_reason == "end_turn" + assert response.field_meta == { + "tool_name": "teleport", + "teleport": { + "status": "completed", + "url": "https://chat.example.com/code/1/2", + }, + } + assert service.prompts == ["continue this task (continue)"] + assert _get_message_texts(acp_agent_loop) == [] + + tool_updates = _get_tool_updates(acp_agent_loop) + assert isinstance(tool_updates[0], ToolCallStart) + assert tool_updates[0].title == "Teleporting session to Vibe Code Web..." + assert tool_updates[-1].status == "completed" + assert tool_updates[-1].title == "Teleported to Vibe Code Web" + assert [update.field_meta for update in tool_updates] == [ + {"tool_name": "teleport", "teleport": {"status": "starting"}}, + {"tool_name": "teleport", "teleport": {"status": "preparing_workspace"}}, + {"tool_name": "teleport", "teleport": {"status": "starting_workflow"}}, + { + "tool_name": "teleport", + "teleport": { + "status": "completed", + "url": "https://chat.example.com/code/1/2", + }, + }, + ] + + @pytest.mark.asyncio + async def test_teleport_push_required_requests_permission( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + class FakeTeleportService: + response: TeleportPushResponseEvent | None + + def __init__(self) -> None: + self.response = None + + async def __aenter__(self) -> FakeTeleportService: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def execute(self, prompt: str) -> AsyncGenerator[object, object]: + yield TeleportCheckingGitEvent() + response = yield TeleportPushRequiredEvent( + unpushed_count=2, branch_not_pushed=False + ) + self.response = cast(TeleportPushResponseEvent, response) + yield TeleportPushingEvent() + yield TeleportCompleteEvent(url="https://chat.example.com/code/1/2") + + session_id = await _new_session_and_clear(acp_agent_loop) + session = acp_agent_loop.sessions[session_id] + session.agent_loop.messages.append( + LLMMessage(role=Role.user, content="ship it") + ) + service = FakeTeleportService() + _set_teleport_service(session.agent_loop, service) + + client = _get_client(acp_agent_loop) + client.request_permission = AsyncMock( + return_value=RequestPermissionResponse( + outcome=AllowedOutcome( + outcome="selected", option_id=TELEPORT_PUSH_OPTION_ID + ) + ) + ) + + response = await _prompt(acp_agent_loop, session_id, "/teleport") + + assert response.field_meta == { + "tool_name": "teleport", + "teleport": { + "status": "completed", + "url": "https://chat.example.com/code/1/2", + }, + } + assert service.response == TeleportPushResponseEvent(approved=True) + request_permission = cast(AsyncMock, client.request_permission) + request_permission.assert_awaited_once() + await_args = request_permission.await_args + assert await_args is not None + kwargs = await_args.kwargs + assert ( + kwargs["tool_call"].title + == "You have 2 unpushed commits. Push to continue?" + ) + assert kwargs["tool_call"].field_meta == { + "tool_name": "teleport", + "teleport": { + "status": "push_required", + "unpushedCount": 2, + "branchNotPushed": False, + }, + } + assert [option.name for option in kwargs["options"]] == [ + "Push and continue", + "Cancel", + ] + assert [update.field_meta for update in _get_tool_updates(acp_agent_loop)] == [ + {"tool_name": "teleport", "teleport": {"status": "starting"}}, + {"tool_name": "teleport", "teleport": {"status": "preparing_workspace"}}, + { + "tool_name": "teleport", + "teleport": { + "status": "push_required", + "unpushedCount": 2, + "branchNotPushed": False, + }, + }, + {"tool_name": "teleport", "teleport": {"status": "syncing_remote"}}, + { + "tool_name": "teleport", + "teleport": { + "status": "completed", + "url": "https://chat.example.com/code/1/2", + }, + }, + ] + + @pytest.mark.asyncio + async def test_teleport_push_denied_marks_tool_call_failed( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + class FakeTeleportService: + async def __aenter__(self) -> FakeTeleportService: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def execute(self, prompt: str) -> AsyncGenerator[object, object]: + response = yield TeleportPushRequiredEvent() + if ( + not isinstance(response, TeleportPushResponseEvent) + or not response.approved + ): + raise ServiceTeleportError( + "Teleport cancelled: changes not pushed." + ) + + session_id = await _new_session_and_clear(acp_agent_loop) + session = acp_agent_loop.sessions[session_id] + session.agent_loop.messages.append( + LLMMessage(role=Role.user, content="ship it") + ) + _set_teleport_service(session.agent_loop, FakeTeleportService()) + + client = _get_client(acp_agent_loop) + client.request_permission = AsyncMock( + return_value=RequestPermissionResponse( + outcome=DeniedOutcome(outcome="cancelled") + ) + ) + + response = await _prompt(acp_agent_loop, session_id, "/teleport") + + assert response.field_meta == { + "tool_name": "teleport", + "teleport": {"status": "failed"}, + } + failed = _get_tool_updates(acp_agent_loop)[-1] + assert failed.status == "failed" + assert failed.title == "Teleport failed" + assert failed.raw_output == "Teleport cancelled: changes not pushed." + assert failed.field_meta == { + "tool_name": "teleport", + "teleport": {"status": "failed"}, + } + + class TestHandleReload: @pytest.mark.asyncio async def test_reload_calls_reload_with_initial_messages( @@ -213,6 +527,19 @@ class TestCommandFallthrough: texts = _get_message_texts(acp_agent_loop) assert any("Hi" in t for t in texts) + @pytest.mark.asyncio + async def test_ampersand_message_reaches_agent( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + session_id = await _new_session_and_clear(acp_agent_loop) + response = await _prompt(acp_agent_loop, session_id, "&fix from here") + + assert response.stop_reason == "end_turn" + assert response.field_meta is None + assert _get_tool_updates(acp_agent_loop) == [] + texts = _get_message_texts(acp_agent_loop) + assert any("Hi" in t for t in texts) + class TestAvailableCommandsWithSkills: @pytest.mark.asyncio @@ -224,7 +551,7 @@ class TestAvailableCommandsWithSkills: await acp_agent_loop_with_skills.new_session( cwd=str(Path.cwd()), mcp_servers=[] ) - await asyncio.sleep(0) + await _wait_for_available_commands(acp_agent_loop_with_skills) updates = _get_client(acp_agent_loop_with_skills)._session_updates available = [ @@ -247,7 +574,7 @@ class TestAvailableCommandsWithSkills: await acp_agent_loop_with_skills.new_session( cwd=str(Path.cwd()), mcp_servers=[] ) - await asyncio.sleep(0) + await _wait_for_available_commands(acp_agent_loop_with_skills) updates = _get_client(acp_agent_loop_with_skills)._session_updates available = [ diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index 9b379e8..06e0771 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -43,9 +43,14 @@ def build_acp_agent_loop(*, provider: ProviderConfig | None = None) -> VibeAcpAg ) +@pytest.fixture +def unauthenticated_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + + class TestACPInitialize: @pytest.mark.asyncio - async def test_initialize(self) -> None: + async def test_initialize(self, unauthenticated_env: None) -> None: acp_agent_loop = build_acp_agent_loop() response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION) @@ -62,7 +67,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.14.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.14.1" ) assert response.auth_methods is not None @@ -73,7 +78,9 @@ class TestACPInitialize: assert auth_method.description == BROWSER_AUTH_DESCRIPTION @pytest.mark.asyncio - async def test_initialize_with_terminal_auth(self) -> None: + async def test_initialize_with_terminal_auth( + self, unauthenticated_env: None + ) -> None: """Test initialize with terminal-auth capabilities to check it was included.""" acp_agent_loop = build_acp_agent_loop() client_capabilities = ClientCapabilities(field_meta={"terminal-auth": True}) @@ -94,7 +101,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.14.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.14.1" ) assert response.auth_methods is not None @@ -109,6 +116,8 @@ class TestACPInitialize: assert auth_method.id == "vibe-setup" assert auth_method.name == "Register your API Key" assert auth_method.description == "Register your API Key inside Mistral Vibe" + assert auth_method.args is not None + assert auth_method.args[-1:] == ["--setup"] assert auth_method.field_meta is not None assert "terminal-auth" in auth_method.field_meta terminal_auth_meta = auth_method.field_meta["terminal-auth"] @@ -118,7 +127,9 @@ class TestACPInitialize: assert terminal_auth_meta["label"] == "Mistral Vibe Setup" @pytest.mark.asyncio - async def test_initialize_with_delegated_browser_auth(self) -> None: + async def test_initialize_with_delegated_browser_auth( + self, unauthenticated_env: None + ) -> None: acp_agent_loop = build_acp_agent_loop() client_capabilities = ClientCapabilities( field_meta={"browser-auth-delegated": True} @@ -156,3 +167,43 @@ class TestACPInitialize: response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION) assert response.auth_methods == [] + + @pytest.mark.asyncio + async def test_initialize_omits_auth_methods_for_authenticated_jetbrains_client( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("MISTRAL_API_KEY", "test-api-key") + acp_agent_loop = build_acp_agent_loop() + client_capabilities = ClientCapabilities( + field_meta={"terminal-auth": True, "browser-auth-delegated": True} + ) + client_info = Implementation(name="JetBrains.PyCharm", version="2026.1.2") + + response = await acp_agent_loop.initialize( + protocol_version=PROTOCOL_VERSION, + client_capabilities=client_capabilities, + client_info=client_info, + ) + + assert response.auth_methods == [] + + @pytest.mark.asyncio + async def test_initialize_keeps_auth_methods_for_authenticated_non_jetbrains_client( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("MISTRAL_API_KEY", "test-api-key") + acp_agent_loop = build_acp_agent_loop() + client_capabilities = ClientCapabilities(field_meta={"terminal-auth": True}) + client_info = Implementation(name="zed", version="0.999.0") + + response = await acp_agent_loop.initialize( + protocol_version=PROTOCOL_VERSION, + client_capabilities=client_capabilities, + client_info=client_info, + ) + + assert response.auth_methods is not None + assert {method.id for method in response.auth_methods} == { + "browser-auth", + "vibe-setup", + } diff --git a/tests/acp/test_proxy_setup_acp.py b/tests/acp/test_proxy_setup_acp.py index d45ea38..5ba1ab4 100644 --- a/tests/acp/test_proxy_setup_acp.py +++ b/tests/acp/test_proxy_setup_acp.py @@ -34,14 +34,34 @@ def _get_fake_client(acp_agent_loop: VibeAcpAgentLoop) -> FakeClient: return acp_agent_loop.client +async def _wait_for_available_commands(acp_agent_loop: VibeAcpAgentLoop) -> None: + for _ in range(50): + updates = _get_fake_client(acp_agent_loop)._session_updates + if any(isinstance(u.update, AvailableCommandsUpdate) for u in updates): + return + await asyncio.sleep(0.01) + raise TimeoutError("available commands update was not sent") + + class TestAvailableCommandsUpdate: + @pytest.mark.asyncio + async def test_initial_available_commands_are_delayed_until_after_new_session( + self, acp_agent_loop: VibeAcpAgentLoop + ) -> None: + await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[]) + + updates = _get_fake_client(acp_agent_loop)._session_updates + assert not any(isinstance(u.update, AvailableCommandsUpdate) for u in updates) + + await _wait_for_available_commands(acp_agent_loop) + @pytest.mark.asyncio async def test_available_commands_sent_on_new_session( self, acp_agent_loop: VibeAcpAgentLoop ) -> None: await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[]) - await asyncio.sleep(0) + await _wait_for_available_commands(acp_agent_loop) updates = _get_fake_client(acp_agent_loop)._session_updates available_commands_updates = [ @@ -62,7 +82,7 @@ class TestAvailableCommandsUpdate: ) -> None: await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[]) - await asyncio.sleep(0) + await _wait_for_available_commands(acp_agent_loop) updates = _get_fake_client(acp_agent_loop)._session_updates available_commands_updates = [ diff --git a/tests/acp/test_set_config_option.py b/tests/acp/test_set_config_option.py index b4fae67..bfb4282 100644 --- a/tests/acp/test_set_config_option.py +++ b/tests/acp/test_set_config_option.py @@ -11,6 +11,7 @@ from vibe.acp.acp_agent_loop import VibeAcpAgentLoop from vibe.core.agent_loop import AgentLoop from vibe.core.agents.models import BuiltinAgentName from vibe.core.config import ModelConfig, VibeConfig +from vibe.core.middleware import TurnLimitMiddleware @pytest.fixture @@ -399,3 +400,131 @@ class TestACPSetConfigOptionThinking: ) assert response is None + + +class TestACPSetConfigOptionMaxTurns: + @pytest.mark.asyncio + async def test_set_config_option_max_turns_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 + + response = await acp_agent_loop.set_config_option( + session_id=session_id, config_id="max_turns", value="100" + ) + + assert response is not None + assert acp_session.agent_loop._max_turns == 100 + turn_limits = [ + m + for m in acp_session.agent_loop.middleware_pipeline.middlewares + if isinstance(m, TurnLimitMiddleware) + ] + assert len(turn_limits) == 1 + assert turn_limits[0].max_turns == 100 + + @pytest.mark.asyncio + async def test_set_config_option_max_turns_string_value( + 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="max_turns", value="50" + ) + + assert response is not None + assert acp_session.agent_loop._max_turns == 50 + turn_limits = [ + m + for m in acp_session.agent_loop.middleware_pipeline.middlewares + if isinstance(m, TurnLimitMiddleware) + ] + assert len(turn_limits) == 1 + assert turn_limits[0].max_turns == 50 + + @pytest.mark.asyncio + async def test_set_config_option_max_turns_invalid_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 + acp_session = next( + (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None + ) + assert acp_session is not None + initial_max_turns = acp_session.agent_loop._max_turns + + response = await acp_agent_loop.set_config_option( + session_id=session_id, config_id="max_turns", value="abc" + ) + + assert response is None + assert acp_session.agent_loop._max_turns == initial_max_turns + + @pytest.mark.asyncio + async def test_set_config_option_max_turns_bool_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 + initial_max_turns = acp_session.agent_loop._max_turns + + response = await acp_agent_loop.set_config_option( + session_id=session_id, config_id="max_turns", value=True + ) + + assert response is None + assert acp_session.agent_loop._max_turns == initial_max_turns + + @pytest.mark.asyncio + async def test_set_config_option_max_turns_repeated_set( + 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 + + await acp_agent_loop.set_config_option( + session_id=session_id, config_id="max_turns", value="100" + ) + response = await acp_agent_loop.set_config_option( + session_id=session_id, config_id="max_turns", value="200" + ) + + assert response is not None + assert acp_session.agent_loop._max_turns == 200 + turn_limits = [ + m + for m in acp_session.agent_loop.middleware_pipeline.middlewares + if isinstance(m, TurnLimitMiddleware) + ] + assert len(turn_limits) == 1 + assert turn_limits[0].max_turns == 200 diff --git a/tests/cli/test_startup_update_prompt.py b/tests/cli/test_startup_update_prompt.py new file mode 100644 index 0000000..e800ec9 --- /dev/null +++ b/tests/cli/test_startup_update_prompt.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +import time +from unittest.mock import patch + +import pytest + +from tests.conftest import build_test_vibe_config +from vibe.cli.cli import _maybe_run_startup_update_prompt +from vibe.cli.update_notifier import FileSystemUpdateCacheRepository, UpdateCache +from vibe.setup.update_prompt.update_prompt_dialog import UpdatePromptResult + + +class _BrokenRepository: + async def get(self) -> UpdateCache | None: + raise OSError("disk on fire") + + async def set(self, update_cache: UpdateCache) -> None: + raise OSError("disk on fire") + + +@pytest.fixture +def repository(tmp_path: Path) -> FileSystemUpdateCacheRepository: + return FileSystemUpdateCacheRepository(base_path=tmp_path) + + +def _write_pending_update( + repository: FileSystemUpdateCacheRepository, version: str +) -> None: + asyncio.run( + repository.set( + UpdateCache(latest_version=version, stored_at_timestamp=int(time.time())) + ) + ) + + +def test_no_op_when_update_checks_are_disabled( + repository: FileSystemUpdateCacheRepository, +) -> None: + config = build_test_vibe_config(enable_update_checks=False) + _write_pending_update(repository, "999.0.0") + + with patch("vibe.cli.cli.ask_update_prompt") as mock_ask: + _maybe_run_startup_update_prompt(config, repository) + + mock_ask.assert_not_called() + + +def test_no_op_when_no_pending_update_is_cached( + repository: FileSystemUpdateCacheRepository, +) -> None: + config = build_test_vibe_config(enable_update_checks=True) + + with patch("vibe.cli.cli.ask_update_prompt") as mock_ask: + _maybe_run_startup_update_prompt(config, repository) + + mock_ask.assert_not_called() + + +def test_prompt_is_shown_and_continue_returns_without_exiting( + repository: FileSystemUpdateCacheRepository, +) -> None: + config = build_test_vibe_config(enable_update_checks=True) + _write_pending_update(repository, "999.0.0") + + with patch( + "vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE + ) as mock_ask: + _maybe_run_startup_update_prompt(config, repository) + + mock_ask.assert_called_once() + + +def test_quit_exits_zero(repository: FileSystemUpdateCacheRepository) -> None: + config = build_test_vibe_config(enable_update_checks=True) + _write_pending_update(repository, "999.0.0") + + with ( + patch("vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.QUIT), + pytest.raises(SystemExit) as excinfo, + ): + _maybe_run_startup_update_prompt(config, repository) + + assert excinfo.value.code == 0 + + +def test_successful_update_exits_zero( + repository: FileSystemUpdateCacheRepository, +) -> None: + config = build_test_vibe_config(enable_update_checks=True) + _write_pending_update(repository, "999.0.0") + + with ( + patch( + "vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.UPDATED + ), + pytest.raises(SystemExit) as excinfo, + ): + _maybe_run_startup_update_prompt(config, repository) + + assert excinfo.value.code == 0 + + +def test_failed_update_exits_one(repository: FileSystemUpdateCacheRepository) -> None: + config = build_test_vibe_config(enable_update_checks=True) + _write_pending_update(repository, "999.0.0") + + with ( + patch( + "vibe.cli.cli.ask_update_prompt", + return_value=UpdatePromptResult.UPDATE_FAILED, + ), + pytest.raises(SystemExit) as excinfo, + ): + _maybe_run_startup_update_prompt(config, repository) + + assert excinfo.value.code == 1 + + +def test_no_op_when_cache_read_raises_oserror() -> None: + config = build_test_vibe_config(enable_update_checks=True) + repository = _BrokenRepository() + + with patch("vibe.cli.cli.ask_update_prompt") as mock_ask: + _maybe_run_startup_update_prompt(config, repository) + + mock_ask.assert_not_called() + + +def test_continue_marks_version_as_dismissed_and_prevents_reprompt( + repository: FileSystemUpdateCacheRepository, +) -> None: + config = build_test_vibe_config(enable_update_checks=True) + _write_pending_update(repository, "999.0.0") + + with patch( + "vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE + ) as mock_ask: + _maybe_run_startup_update_prompt(config, repository) + _maybe_run_startup_update_prompt(config, repository) + + assert mock_ask.call_count == 1 + + +def test_continue_reprompts_when_a_newer_version_appears( + repository: FileSystemUpdateCacheRepository, +) -> None: + config = build_test_vibe_config(enable_update_checks=True) + _write_pending_update(repository, "999.0.0") + + with patch( + "vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE + ) as mock_ask: + _maybe_run_startup_update_prompt(config, repository) + _write_pending_update(repository, "1000.0.0") + _maybe_run_startup_update_prompt(config, repository) + + assert mock_ask.call_count == 2 + + +def test_successful_update_prints_restart_hint( + repository: FileSystemUpdateCacheRepository, capsys: pytest.CaptureFixture[str] +) -> None: + config = build_test_vibe_config(enable_update_checks=True) + _write_pending_update(repository, "999.0.0") + + with ( + patch( + "vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.UPDATED + ), + pytest.raises(SystemExit), + ): + _maybe_run_startup_update_prompt(config, repository) + + out = capsys.readouterr().out + assert "999.0.0" in out + assert "Run" in out and "vibe" in out + + +def test_failed_update_prints_error_message( + repository: FileSystemUpdateCacheRepository, capsys: pytest.CaptureFixture[str] +) -> None: + config = build_test_vibe_config(enable_update_checks=True) + _write_pending_update(repository, "999.0.0") + + with ( + patch( + "vibe.cli.cli.ask_update_prompt", + return_value=UpdatePromptResult.UPDATE_FAILED, + ), + pytest.raises(SystemExit), + ): + _maybe_run_startup_update_prompt(config, repository) + + assert "could not be updated automatically" in capsys.readouterr().out + + +def test_failed_update_does_not_dismiss_so_user_is_reprompted_on_next_launch( + repository: FileSystemUpdateCacheRepository, +) -> None: + config = build_test_vibe_config(enable_update_checks=True) + _write_pending_update(repository, "999.0.0") + + with ( + patch( + "vibe.cli.cli.ask_update_prompt", + return_value=UpdatePromptResult.UPDATE_FAILED, + ), + pytest.raises(SystemExit), + ): + _maybe_run_startup_update_prompt(config, repository) + + cache = asyncio.run(repository.get()) + assert cache is not None + assert cache.dismissed_version is None + + with patch( + "vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE + ) as mock_ask: + _maybe_run_startup_update_prompt(config, repository) + + mock_ask.assert_called_once() diff --git a/tests/conftest.py b/tests/conftest.py index 3273ca8..be3d493 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,7 +54,6 @@ def get_base_config() -> dict[str, Any]: "alias": "devstral-latest", } ], - "enable_auto_update": False, "enable_telemetry": False, } diff --git a/tests/core/test_build_attachment_counts.py b/tests/core/test_build_attachment_counts.py new file mode 100644 index 0000000..dc6093f --- /dev/null +++ b/tests/core/test_build_attachment_counts.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from pathlib import Path + +from vibe.core.telemetry.build_metadata import build_attachment_counts +from vibe.core.telemetry.types import AttachmentKind +from vibe.core.types import ImageAttachment, LLMMessage, Role + + +def _msg(images: list[ImageAttachment] | None) -> LLMMessage: + return LLMMessage(role=Role.user, content="hi", images=images) + + +def _image() -> ImageAttachment: + return ImageAttachment( + path=Path("/tmp/x.png"), alias="x.png", mime_type="image/png" + ) + + +def test_returns_empty_when_message_is_none() -> None: + assert build_attachment_counts(None, supports_images=True) == {} + + +def test_returns_empty_when_no_images() -> None: + assert build_attachment_counts(_msg(None), supports_images=True) == {} + + +def test_counts_images_when_model_supports_them() -> None: + counts = build_attachment_counts(_msg([_image(), _image()]), supports_images=True) + assert counts == {AttachmentKind.IMAGE: 2} + + +def test_drops_images_when_model_does_not_support_them() -> None: + assert build_attachment_counts(_msg([_image()]), supports_images=False) == {} diff --git a/tests/core/test_telemetry_send.py b/tests/core/test_telemetry_send.py index 681dabb..5193965 100644 --- a/tests/core/test_telemetry_send.py +++ b/tests/core/test_telemetry_send.py @@ -15,7 +15,11 @@ from vibe.core.telemetry.build_metadata import ( build_request_metadata, ) from vibe.core.telemetry.send import TelemetryClient -from vibe.core.telemetry.types import EntrypointMetadata, TelemetryRequestMetadata +from vibe.core.telemetry.types import ( + AttachmentKind, + EntrypointMetadata, + TelemetryRequestMetadata, +) from vibe.core.tools.base import BaseTool, ToolPermission from vibe.core.types import Backend from vibe.core.utils import get_user_agent @@ -694,6 +698,43 @@ class TestTelemetryClient: assert properties["call_source"] == "vibe_code" assert properties["call_type"] == "main_call" assert properties["message_id"] is None + assert properties["attachment_counts"] == {} + + def test_send_request_sent_payload_with_attachments( + self, telemetry_events: list[dict[str, Any]] + ) -> None: + config = build_test_vibe_config(enable_telemetry=True) + client = TelemetryClient(config_getter=lambda: config) + + client.send_request_sent( + model="codestral", + nb_context_chars=1234, + nb_context_messages=5, + nb_prompt_chars=42, + call_type="main_call", + attachment_counts={AttachmentKind.IMAGE: 2}, + ) + + assert len(telemetry_events) == 1 + properties = telemetry_events[0]["properties"] + assert properties["attachment_counts"] == {"image": 2} + + def test_send_request_sent_payload_drops_zero_counts( + self, telemetry_events: list[dict[str, Any]] + ) -> None: + config = build_test_vibe_config(enable_telemetry=True) + client = TelemetryClient(config_getter=lambda: config) + + client.send_request_sent( + model="codestral", + nb_context_chars=1234, + nb_context_messages=5, + nb_prompt_chars=42, + call_type="main_call", + attachment_counts={AttachmentKind.IMAGE: 0}, + ) + + assert telemetry_events[0]["properties"]["attachment_counts"] == {} def test_send_user_rating_feedback_payload( self, telemetry_events: list[dict[str, Any]] diff --git a/tests/e2e/common.py b/tests/e2e/common.py index 1d6b844..fbb0d8c 100644 --- a/tests/e2e/common.py +++ b/tests/e2e/common.py @@ -28,7 +28,6 @@ def write_e2e_config(vibe_home: Path, api_base: str) -> None: "\n".join([ 'active_model = "mock-model"', "enable_update_checks = false", - "enable_auto_update = false", "disable_welcome_banner_animation = true", "", "[[providers]]", diff --git a/tests/setup/__init__.py b/tests/setup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/setup/test_update_prompt_dialog.py b/tests/setup/test_update_prompt_dialog.py new file mode 100644 index 0000000..1fbad10 --- /dev/null +++ b/tests/setup/test_update_prompt_dialog.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from vibe.setup.update_prompt.update_prompt_dialog import ( + UpdateChoice, + UpdatePromptApp, + UpdatePromptResult, +) + + +async def _await_update_completion(app: UpdatePromptApp) -> None: + while app._update_task is None: + await asyncio.sleep(0.01) + try: + await app._update_task + except Exception: + pass + + +@pytest.mark.asyncio +async def test_dialog_returns_continue_on_continue_selection() -> None: + app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0") + + async with app.run_test() as pilot: + await pilot.press("right") + await pilot.press("enter") + await pilot.pause() + + assert app.return_value is UpdatePromptResult.CONTINUE + + +@pytest.mark.asyncio +async def test_dialog_returns_updated_when_update_command_succeeds() -> None: + app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0") + + with patch( + "vibe.setup.update_prompt.update_prompt_dialog.do_update", + new=AsyncMock(return_value=True), + ): + async with app.run_test() as pilot: + await pilot.press("enter") + await _await_update_completion(app) + await pilot.pause() + + assert app.return_value is UpdatePromptResult.UPDATED + + +@pytest.mark.asyncio +async def test_dialog_returns_update_failed_when_update_command_fails() -> None: + app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0") + + with patch( + "vibe.setup.update_prompt.update_prompt_dialog.do_update", + new=AsyncMock(return_value=False), + ): + async with app.run_test() as pilot: + await pilot.press("enter") + await _await_update_completion(app) + await pilot.pause() + + assert app.return_value is UpdatePromptResult.UPDATE_FAILED + + +@pytest.mark.asyncio +async def test_dialog_default_selection_is_update() -> None: + app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0") + + async with app.run_test() as pilot: + await pilot.pause() + assert app._dialog is not None + assert app._dialog.selected is UpdateChoice.UPDATE + + +@pytest.mark.asyncio +async def test_dialog_returns_quit_on_ctrl_q() -> None: + app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0") + + async with app.run_test() as pilot: + await pilot.press("ctrl+q") + await pilot.pause() + + assert app.return_value is UpdatePromptResult.QUIT + + +@pytest.mark.asyncio +async def test_dialog_returns_update_failed_when_do_update_raises() -> None: + app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0") + + with patch( + "vibe.setup.update_prompt.update_prompt_dialog.do_update", + new=AsyncMock(side_effect=OSError("boom")), + ): + async with app.run_test() as pilot: + await pilot.press("enter") + await _await_update_completion(app) + await pilot.pause() + + assert app.return_value is UpdatePromptResult.UPDATE_FAILED + + +@pytest.mark.asyncio +async def test_ctrl_q_during_update_cancels_subprocess_and_quits() -> None: + app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0") + + update_started = asyncio.Event() + + async def slow_update() -> bool: + update_started.set() + await asyncio.sleep(60) + return True + + with patch( + "vibe.setup.update_prompt.update_prompt_dialog.do_update", new=slow_update + ): + async with app.run_test() as pilot: + await pilot.press("enter") + await update_started.wait() + await pilot.press("ctrl+q") + await pilot.pause() + + assert app.return_value is UpdatePromptResult.QUIT + assert app._update_task is not None + assert app._update_task.cancelled() or app._update_task.done() 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 deleted file mode 100644 index ab02dbe..0000000 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg +++ /dev/null @@ -1,204 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - SnapshotTestAppWithUpdate - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - -What's New -• Feature 1 -• Feature 2Update available -1.0.4 => 1000.2.0 -Please update mistral-vibe with your package manager - -────────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─ -> - - -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -/test/workdir0% of 200k tokens - - - diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_update_prompt/test_snapshot_update_prompt_continue_selected.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_update_prompt/test_snapshot_update_prompt_continue_selected.svg new file mode 100644 index 0000000..762501d --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_update_prompt/test_snapshot_update_prompt_continue_selected.svg @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UpdatePromptSnapshotApp + + + + + + + + + + + + + +╭────────────────────────────────────────────────────────────────────╮ + +A new Vibe release is available + +1.0.0 → 2.0.0 + +  Update now› Continue with current version + +← → navigate  Enter select + +╰────────────────────────────────────────────────────────────────────╯ + + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_update_prompt/test_snapshot_update_prompt_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_update_prompt/test_snapshot_update_prompt_initial.svg new file mode 100644 index 0000000..e1d7ab9 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_update_prompt/test_snapshot_update_prompt_initial.svg @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UpdatePromptSnapshotApp + + + + + + + + + + + + + +╭────────────────────────────────────────────────────────────────────╮ + +A new Vibe release is available + +1.0.0 → 2.0.0 + +› Update now  Continue with current version + +← → navigate  Enter select + +╰────────────────────────────────────────────────────────────────────╯ + + + + + + + + diff --git a/tests/snapshots/test_ui_snapshot_release_update_notification.py b/tests/snapshots/test_ui_snapshot_release_update_notification.py deleted file mode 100644 index cbec0e4..0000000 --- a/tests/snapshots/test_ui_snapshot_release_update_notification.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from unittest.mock import patch - -from textual.pilot import Pilot - -from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config -from tests.snapshots.snap_compare import SnapCompare -from tests.update_notifier.adapters.fake_update_cache_repository import ( - FakeUpdateCacheRepository, -) -from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway -from vibe.cli.update_notifier import Update, UpdateCache - - -class SnapshotTestAppWithUpdate(BaseSnapshotTestApp): - def __init__(self): - config = default_config() - config.enable_update_checks = True - update_notifier = FakeUpdateGateway(update=Update(latest_version="1000.2.0")) - update_cache_repository = FakeUpdateCacheRepository( - UpdateCache( - latest_version="1.0.4", - stored_at_timestamp=0, - seen_whats_new_version=None, - ) - ) - super().__init__( - config=config, - update_notifier=update_notifier, - update_cache_repository=update_cache_repository, - current_version="1.0.4", - ) - - -def test_snapshot_shows_release_update_notification( - snap_compare: SnapCompare, tmp_path: Path -) -> None: - whats_new_file = tmp_path / "whats_new.md" - whats_new_file.write_text("# What's New\n\n- Feature 1\n- Feature 2") - - async def run_before(pilot: Pilot) -> None: - await pilot.pause(0.2) - - with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path): - assert snap_compare( - "test_ui_snapshot_release_update_notification.py:SnapshotTestAppWithUpdate", - terminal_size=(120, 36), - run_before=run_before, - ) diff --git a/tests/snapshots/test_ui_snapshot_update_prompt.py b/tests/snapshots/test_ui_snapshot_update_prompt.py new file mode 100644 index 0000000..8e6b7f0 --- /dev/null +++ b/tests/snapshots/test_ui_snapshot_update_prompt.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path + +from textual.pilot import Pilot + +from tests.snapshots.snap_compare import SnapCompare +from vibe.setup.update_prompt import update_prompt_dialog +from vibe.setup.update_prompt.update_prompt_dialog import UpdatePromptApp + + +class UpdatePromptSnapshotApp(UpdatePromptApp): + CSS_PATH = str( + Path(update_prompt_dialog.__file__).parent / "update_prompt_dialog.tcss" + ) + + def __init__(self) -> None: + super().__init__(current_version="1.0.0", latest_version="2.0.0") + + +def test_snapshot_update_prompt_initial(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + + assert snap_compare( + "test_ui_snapshot_update_prompt.py:UpdatePromptSnapshotApp", + terminal_size=(80, 20), + run_before=run_before, + ) + + +def test_snapshot_update_prompt_continue_selected(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await pilot.pause(0.2) + await pilot.press("right") + await pilot.pause(0.1) + + assert snap_compare( + "test_ui_snapshot_update_prompt.py:UpdatePromptSnapshotApp", + terminal_size=(80, 20), + run_before=run_before, + ) diff --git a/tests/update_notifier/test_ui_update_notification.py b/tests/update_notifier/test_ui_update_notification.py index e08b8b8..00a7e51 100644 --- a/tests/update_notifier/test_ui_update_notification.py +++ b/tests/update_notifier/test_ui_update_notification.py @@ -7,7 +7,6 @@ import time from unittest.mock import patch import pytest -from textual.app import Notification from tests.conftest import build_test_vibe_app, build_test_vibe_config from tests.update_notifier.adapters.fake_update_cache_repository import ( @@ -50,21 +49,6 @@ def build_update_test_app( return _build -async def _wait_for_notification( - app: VibeApp, pilot, *, timeout: float = 1.0, interval: float = 0.05 -) -> Notification: - loop = asyncio.get_running_loop() - deadline = loop.time() + timeout - - while loop.time() < deadline: - notifications = list(app._notifications) - if notifications: - return notifications[-1] - await pilot.pause(interval) - - pytest.fail("Notification not displayed") - - async def _assert_no_notifications( app: VibeApp, pilot, *, timeout: float = 1.0, interval: float = 0.05 ) -> None: @@ -85,25 +69,25 @@ def vibe_config_with_update_checks_enabled() -> VibeConfig: @pytest.mark.asyncio -async def test_ui_displays_update_notification( +async def test_ui_writes_pending_update_to_cache_without_notifying( build_update_test_app: Callable[..., VibeApp], ) -> None: notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0")) - app = build_update_test_app(update_notifier=notifier) + repository = FakeUpdateCacheRepository() + app = build_update_test_app( + update_notifier=notifier, update_cache_repository=repository + ) async with app.run_test() as pilot: - notification = await _wait_for_notification(app, pilot, timeout=0.3) + await _assert_no_notifications(app, pilot, timeout=0.3) - assert notification.severity == "information" - assert notification.title == "Update available" - assert ( - notification.message - == "0.1.0 => 0.2.0\nPlease update mistral-vibe with your package manager" - ) + assert notifier.fetch_update_calls == 1 + assert repository.update_cache is not None + assert repository.update_cache.latest_version == "0.2.0" @pytest.mark.asyncio -async def test_ui_does_not_display_update_notification_when_not_available( +async def test_ui_does_not_notify_when_no_update_is_available( build_update_test_app: Callable[..., VibeApp], ) -> None: notifier = FakeUpdateGateway(update=None) @@ -115,7 +99,7 @@ async def test_ui_does_not_display_update_notification_when_not_available( @pytest.mark.asyncio -async def test_ui_displays_warning_toast_when_check_fails( +async def test_ui_does_not_notify_when_gateway_errors( build_update_test_app: Callable[..., VibeApp], ) -> None: notifier = FakeUpdateGateway( @@ -124,13 +108,7 @@ async def test_ui_displays_warning_toast_when_check_fails( app = build_update_test_app(update_notifier=notifier) async with app.run_test() as pilot: - await pilot.pause(0.3) - notifications = list(app._notifications) - - assert notifications - warning = notifications[-1] - assert warning.severity == "warning" - assert "forbidden" in warning.message.lower() + await _assert_no_notifications(app, pilot, timeout=0.3) @pytest.mark.asyncio @@ -167,11 +145,11 @@ async def test_ui_does_not_show_toast_when_update_is_known_in_recent_cache_alrea @pytest.mark.asyncio -async def test_ui_does_show_toast_when_cache_entry_is_too_old( +async def test_ui_refetches_and_updates_cache_when_cache_entry_is_too_old( build_update_test_app: Callable[..., VibeApp], ) -> None: timestamp_two_days_ago = int(time.time()) - 2 * 24 * 60 * 60 - notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0")) + notifier = FakeUpdateGateway(update=Update(latest_version="0.3.0")) update_cache = UpdateCache( latest_version="0.2.0", stored_at_timestamp=timestamp_two_days_ago ) @@ -181,18 +159,11 @@ async def test_ui_does_show_toast_when_cache_entry_is_too_old( ) async with app.run_test() as pilot: - await pilot.pause(0.3) - notifications = list(app._notifications) + await _assert_no_notifications(app, pilot, timeout=0.3) - assert notifications - notification = notifications[-1] - assert notification.severity == "information" - assert notification.title == "Update available" - assert ( - notification.message - == "0.1.0 => 0.2.0\nPlease update mistral-vibe with your package manager" - ) assert notifier.fetch_update_calls == 1 + assert update_cache_repository.update_cache is not None + assert update_cache_repository.update_cache.latest_version == "0.3.0" async def _wait_for_whats_new_message( @@ -339,51 +310,3 @@ async def test_ui_does_not_display_whats_new_when_file_does_not_exist( assert repository.update_cache is not None assert repository.update_cache.seen_whats_new_version == "1.0.0" - - -@pytest.mark.asyncio -async def test_ui_displays_success_notification_when_auto_update_succeeds( - build_update_test_app: Callable[..., VibeApp], -) -> None: - config = build_test_vibe_config(enable_update_checks=True, enable_auto_update=True) - notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0")) - - with patch("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["true"]): - app = build_update_test_app(update_notifier=notifier, config=config) - - async with app.run_test() as pilot: - await pilot.pause(0.3) - notifications = list(app._notifications) - - assert notifications, "No notifications displayed" - notification = notifications[-1] - assert notification.severity == "information" - assert notification.title == "Update successful" - assert ( - notification.message - == "0.1.0 => 0.2.0\nVibe was updated successfully. Please restart to use the new version." - ) - - -@pytest.mark.asyncio -async def test_ui_displays_update_notification_when_auto_update_fails( - build_update_test_app: Callable[..., VibeApp], -) -> None: - config = build_test_vibe_config(enable_update_checks=True, enable_auto_update=True) - notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0")) - - with patch("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["false"]): - app = build_update_test_app(update_notifier=notifier, config=config) - - async with app.run_test() as pilot: - await pilot.pause(0.3) - notifications = list(app._notifications) - - assert notifications - notification = notifications[-1] - assert notification.severity == "information" - assert notification.title == "Update available" - assert ( - notification.message - == "0.1.0 => 0.2.0\nPlease update mistral-vibe with your package manager" - ) diff --git a/tests/update_notifier/test_update_use_case.py b/tests/update_notifier/test_update_use_case.py index 7112203..11e26b3 100644 --- a/tests/update_notifier/test_update_use_case.py +++ b/tests/update_notifier/test_update_use_case.py @@ -12,7 +12,12 @@ from vibe.cli.update_notifier import ( UpdateGatewayCause, UpdateGatewayError, ) -from vibe.cli.update_notifier.update import UpdateError, get_update_if_available +from vibe.cli.update_notifier.update import ( + UpdateError, + get_pending_update_from_cache, + get_update_if_available, + mark_update_as_dismissed, +) @pytest.fixture @@ -300,3 +305,136 @@ async def test_updates_cache_timestamp_with_current_version_when_gateway_errors( assert update_cache_repository.update_cache is not None assert update_cache_repository.update_cache.latest_version == "1.0.0" assert update_cache_repository.update_cache.stored_at_timestamp == current_timestamp + + +@pytest.mark.asyncio +async def test_get_pending_update_from_cache_returns_none_when_cache_is_empty() -> None: + repository = FakeUpdateCacheRepository() + + assert await get_pending_update_from_cache(repository, "1.0.0") is None + + +@pytest.mark.asyncio +async def test_get_pending_update_from_cache_returns_none_when_cached_version_equals_current() -> ( + None +): + repository = FakeUpdateCacheRepository( + update_cache=UpdateCache(latest_version="1.0.0", stored_at_timestamp=0) + ) + + assert await get_pending_update_from_cache(repository, "1.0.0") is None + + +@pytest.mark.asyncio +async def test_get_pending_update_from_cache_returns_none_when_cached_version_is_older() -> ( + None +): + repository = FakeUpdateCacheRepository( + update_cache=UpdateCache(latest_version="0.9.0", stored_at_timestamp=0) + ) + + assert await get_pending_update_from_cache(repository, "1.0.0") is None + + +@pytest.mark.asyncio +async def test_get_pending_update_from_cache_returns_version_when_newer_exists() -> ( + None +): + repository = FakeUpdateCacheRepository( + update_cache=UpdateCache(latest_version="1.2.3", stored_at_timestamp=0) + ) + + assert await get_pending_update_from_cache(repository, "1.0.0") == "1.2.3" + + +@pytest.mark.asyncio +async def test_get_pending_update_from_cache_returns_none_when_current_is_invalid() -> ( + None +): + repository = FakeUpdateCacheRepository( + update_cache=UpdateCache(latest_version="1.2.3", stored_at_timestamp=0) + ) + + assert await get_pending_update_from_cache(repository, "not-a-version") is None + + +@pytest.mark.asyncio +async def test_get_pending_update_from_cache_returns_none_when_dismissed() -> None: + repository = FakeUpdateCacheRepository( + update_cache=UpdateCache( + latest_version="1.2.3", stored_at_timestamp=0, dismissed_version="1.2.3" + ) + ) + + assert await get_pending_update_from_cache(repository, "1.0.0") is None + + +@pytest.mark.asyncio +async def test_get_pending_update_from_cache_returns_version_when_dismissed_is_older() -> ( + None +): + repository = FakeUpdateCacheRepository( + update_cache=UpdateCache( + latest_version="1.3.0", stored_at_timestamp=0, dismissed_version="1.2.3" + ) + ) + + assert await get_pending_update_from_cache(repository, "1.0.0") == "1.3.0" + + +@pytest.mark.asyncio +async def test_mark_update_as_dismissed_persists_version_and_preserves_other_fields() -> ( + None +): + repository = FakeUpdateCacheRepository( + update_cache=UpdateCache( + latest_version="1.2.3", + stored_at_timestamp=42, + seen_whats_new_version="1.0.0", + ) + ) + + await mark_update_as_dismissed(repository, "1.2.3") + + assert repository.update_cache is not None + assert repository.update_cache.latest_version == "1.2.3" + assert repository.update_cache.stored_at_timestamp == 42 + assert repository.update_cache.seen_whats_new_version == "1.0.0" + assert repository.update_cache.dismissed_version == "1.2.3" + + +@pytest.mark.asyncio +async def test_mark_update_as_dismissed_is_a_noop_when_cache_is_empty() -> None: + repository = FakeUpdateCacheRepository() + + await mark_update_as_dismissed(repository, "1.2.3") + + assert repository.update_cache is None + + +@pytest.mark.asyncio +async def test_writing_cache_preserves_seen_whats_new_and_dismissed_version( + current_timestamp: int, +) -> None: + update_notifier = FakeUpdateGateway(update=Update(latest_version="1.0.2")) + timestamp_two_days_ago = current_timestamp - 48 * 60 * 60 + update_cache_repository = FakeUpdateCacheRepository( + update_cache=UpdateCache( + latest_version="1.0.1", + stored_at_timestamp=timestamp_two_days_ago, + seen_whats_new_version="1.0.0", + dismissed_version="1.0.1", + ) + ) + + await get_update_if_available( + update_notifier, + current_version="1.0.0", + update_cache_repository=update_cache_repository, + get_current_timestamp=lambda: current_timestamp, + ) + + assert update_cache_repository.update_cache is not None + assert update_cache_repository.update_cache.latest_version == "1.0.2" + assert update_cache_repository.update_cache.seen_whats_new_version == "1.0.0" + assert update_cache_repository.update_cache.dismissed_version == "1.0.1" diff --git a/uv.lock b/uv.lock index befca6d..9040590 100644 --- a/uv.lock +++ b/uv.lock @@ -825,7 +825,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.14.0" +version = "2.14.1" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, diff --git a/vibe/__init__.py b/vibe/__init__.py index 72094a1..110614e 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.14.0" +__version__ = "2.14.1" diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py index 773c103..c7f7ee2 100644 --- a/vibe/acp/acp_agent_loop.py +++ b/vibe/acp/acp_agent_loop.py @@ -72,7 +72,7 @@ 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.commands import AcpCommandAvailabilityContext, AcpCommandRegistry from vibe.acp.exceptions import ( ConfigurationError, ContextTooLongError, @@ -86,6 +86,7 @@ from vibe.acp.exceptions import ( UnauthenticatedError, ) from vibe.acp.session import AcpSessionLoop +from vibe.acp.teleport import handle_teleport_command from vibe.acp.title import acp_blocks_to_title_segments from vibe.acp.tools.base import BaseAcpTool from vibe.acp.tools.events import ToolTerminalOpenedEvent @@ -109,6 +110,7 @@ from vibe.acp.utils import ( create_tool_result_replay, create_user_message_replay, get_proxy_help_text, + is_jetbrains_client, is_valid_acp_mode, make_thinking_response, ) @@ -186,6 +188,7 @@ from vibe.setup.onboarding.context import OnboardingContext logger = logging.getLogger("vibe") NON_INTERACTIVE_DISABLED_TOOLS = ["ask_user_question", "exit_plan_mode"] +INITIAL_AVAILABLE_COMMANDS_DELAY_SECONDS = 0.1 def _merge_non_interactive_disabled_tools(config: VibeConfig) -> None: @@ -354,6 +357,7 @@ class VibeAcpAgentLoop(AcpAgent): id="vibe-setup", name="Register your API Key", description="Register your API Key inside Mistral Vibe", + args=args, field_meta={ "terminal-auth": { "command": command, @@ -448,6 +452,12 @@ class VibeAcpAgentLoop(AcpAgent): if supports_terminal_auth: auth_methods.append(self._build_terminal_auth_method(command, args)) + # JetBrains preemptively shows the auth UI as soon as `authMethods` is + # non-empty; suppress methods for already-authenticated JetBrains clients. + _, auth_state = self._assess_current_auth_state() + if is_jetbrains_client(self.client_info) and auth_state.can_use_active_provider: + auth_methods = [] + response = InitializeResponse( agent_capabilities=AgentCapabilities( load_session=True, @@ -606,7 +616,11 @@ class VibeAcpAgentLoop(AcpAgent): async def _create_acp_session( self, session_id: str, agent_loop: AgentLoop ) -> AcpSessionLoop: - command_registry = AcpCommandRegistry() + command_registry = AcpCommandRegistry( + availability_context=AcpCommandAvailabilityContext( + vibe_code_enabled=agent_loop.base_config.vibe_code_enabled + ) + ) session = AcpSessionLoop( id=session_id, agent_loop=agent_loop, command_registry=command_registry ) @@ -620,11 +634,17 @@ class VibeAcpAgentLoop(AcpAgent): if not agent_loop.bypass_tool_permissions: agent_loop.set_approval_callback(self._create_approval_callback(session.id)) - session.spawn(self._send_available_commands(session)) + session.spawn(self._send_initial_available_commands(session)) session.spawn(self._warm_up_agent_loop(agent_loop)) return session + async def _send_initial_available_commands(self, session: AcpSessionLoop) -> None: + # Zed can drop session/update notifications sent before it registers + # the session returned by session/new, so delay initial command discovery. + await asyncio.sleep(INITIAL_AVAILABLE_COMMANDS_DELAY_SECONDS) + await self._send_available_commands(session) + async def _warm_up_agent_loop(self, agent_loop: AgentLoop) -> None: """Proactively await deferred init so `vibe.ready` telemetry is emitted without waiting for the user's first prompt. Errors are swallowed here @@ -1052,6 +1072,14 @@ class VibeAcpAgentLoop(AcpAgent): success = await self._apply_thinking_change( session, cast(ThinkingLevel, value) ) + case "max_turns" if isinstance(value, str): + try: + max_turns = int(value) + except ValueError: + success = False + else: + session.agent_loop.set_max_turns(max_turns) + success = True case _: success = False @@ -1701,6 +1729,11 @@ class VibeAcpAgentLoop(AcpAgent): ) return PromptResponse(stop_reason="end_turn", user_message_id=message_id) + async def _handle_teleport( + self, session: AcpSessionLoop, text_prompt: str, message_id: str + ) -> PromptResponse: + return await handle_teleport_command(self.client, session, message_id) + async def _handle_help( self, session: AcpSessionLoop, text_prompt: str, message_id: str ) -> PromptResponse: diff --git a/vibe/acp/commands/__init__.py b/vibe/acp/commands/__init__.py index 5d08b7a..d28826d 100644 --- a/vibe/acp/commands/__init__.py +++ b/vibe/acp/commands/__init__.py @@ -1,5 +1,5 @@ from __future__ import annotations -from vibe.acp.commands.registry import AcpCommandRegistry +from vibe.acp.commands.registry import AcpCommandAvailabilityContext, AcpCommandRegistry -__all__ = ["AcpCommandRegistry"] +__all__ = ["AcpCommandAvailabilityContext", "AcpCommandRegistry"] diff --git a/vibe/acp/commands/registry.py b/vibe/acp/commands/registry.py index 28396b0..223c62d 100644 --- a/vibe/acp/commands/registry.py +++ b/vibe/acp/commands/registry.py @@ -6,6 +6,19 @@ from dataclasses import dataclass, field type OnCommandsChanged = Callable[[], Awaitable[None]] +@dataclass(frozen=True) +class AcpCommandAvailabilityContext: + """Context used to decide whether a command should be advertised.""" + + vibe_code_enabled: bool = False + + def is_teleport_available(self) -> bool: + return self.vibe_code_enabled + + +type CommandAvailability = Callable[[AcpCommandAvailabilityContext], bool] + + @dataclass(frozen=True) class AcpCommand: """Command advertised to ACP clients via available_commands_update.""" @@ -14,18 +27,31 @@ class AcpCommand: description: str handler: str input_hint: str | None = None + is_available: CommandAvailability | None = None @dataclass class AcpCommandRegistry: """Registry of ACP commands. Notifies listeners when commands change.""" + availability_context: AcpCommandAvailabilityContext = field( + default_factory=AcpCommandAvailabilityContext + ) _commands: dict[str, AcpCommand] = field(default_factory=dict) _on_changed: OnCommandsChanged | None = None def __post_init__(self) -> None: if not self._commands: - self._commands = _build_commands() + self._commands = { + name: command + for name, command in _build_commands().items() + if self._is_available(command) + } + + def _is_available(self, command: AcpCommand) -> bool: + if command.is_available is None: + return True + return command.is_available(self.availability_context) def set_on_changed(self, callback: OnCommandsChanged) -> None: self._on_changed = callback @@ -65,6 +91,12 @@ def _build_commands() -> dict[str, AcpCommand]: description="Show path to current session log directory", handler="_handle_log", ), + "teleport": AcpCommand( + name="teleport", + description="Teleport session to Vibe Code Web", + handler="_handle_teleport", + is_available=AcpCommandAvailabilityContext.is_teleport_available, + ), "proxy-setup": AcpCommand( name="proxy-setup", description="Configure proxy and SSL certificate settings", diff --git a/vibe/acp/teleport.py b/vibe/acp/teleport.py new file mode 100644 index 0000000..ddfdb96 --- /dev/null +++ b/vibe/acp/teleport.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +from contextlib import aclosing +from typing import Literal, cast +from uuid import uuid4 + +from acp import Client, PromptResponse +from acp.schema import ( + AgentMessageChunk, + AllowedOutcome, + ContentToolCallContent, + PermissionOption, + TextContentBlock, + ToolCallProgress, + ToolCallStart, + ToolCallStatus, + ToolCallUpdate, +) + +from vibe.acp.session import AcpSessionLoop +from vibe.core.agent_loop import TeleportError +from vibe.core.teleport.telemetry import send_teleport_early_failure_telemetry +from vibe.core.teleport.types import ( + TeleportCheckingGitEvent, + TeleportCompleteEvent, + TeleportPushingEvent, + TeleportPushRequiredEvent, + TeleportPushResponseEvent, + TeleportStartingWorkflowEvent, +) +from vibe.core.types import Role + +TELEPORT_PUSH_OPTION_ID = "teleport_push_and_continue" +TELEPORT_CANCEL_OPTION_ID = "teleport_cancel" +TELEPORT_FIELD_META_KEY = "teleport" +type TeleportAcpStatus = Literal[ + "starting", + "preparing_workspace", + "push_required", + "syncing_remote", + "starting_workflow", + "completed", + "failed", + "no_history", + "unavailable", +] + + +def _teleport_field_meta( + status: TeleportAcpStatus, + *, + url: str | None = None, + unpushed_count: int | None = None, + branch_not_pushed: bool | None = None, +) -> dict[str, object]: + teleport_meta: dict[str, object] = {"status": status} + if url is not None: + teleport_meta["url"] = url + if unpushed_count is not None: + teleport_meta["unpushedCount"] = unpushed_count + if branch_not_pushed is not None: + teleport_meta["branchNotPushed"] = branch_not_pushed + return {"tool_name": "teleport", TELEPORT_FIELD_META_KEY: teleport_meta} + + +def _teleport_progress_update( + tool_call_id: str, + *, + title: str, + status: ToolCallStatus = "in_progress", + text: str | None = None, + raw_output: str | None = None, + url: str | None = None, + teleport_status: TeleportAcpStatus, + unpushed_count: int | None = None, + branch_not_pushed: bool | None = None, +) -> ToolCallProgress: + return ToolCallProgress( + session_update="tool_call_update", + tool_call_id=tool_call_id, + title=title, + kind="other", + status=status, + raw_output=raw_output, + content=( + [ + ContentToolCallContent( + type="content", content=TextContentBlock(type="text", text=text) + ) + ] + if text + else None + ), + field_meta=_teleport_field_meta( + teleport_status, + url=url, + unpushed_count=unpushed_count, + branch_not_pushed=branch_not_pushed, + ), + ) + + +async def _teleport_command_reply( + client: Client, + session: AcpSessionLoop, + text: str, + message_id: str, + *, + field_meta: dict[str, object], +) -> PromptResponse: + await client.session_update( + session_id=session.id, + update=AgentMessageChunk( + session_update="agent_message_chunk", + content=TextContentBlock(type="text", text=text), + message_id=str(uuid4()), + field_meta=field_meta, + ), + ) + return PromptResponse( + stop_reason="end_turn", user_message_id=message_id, field_meta=field_meta + ) + + +async def _request_teleport_push_approval( + client: Client, + session: AcpSessionLoop, + tool_call_id: str, + *, + count: int, + branch_not_pushed: bool, +) -> TeleportPushResponseEvent: + if branch_not_pushed: + question = "Your branch doesn't exist on remote. Push to continue?" + else: + word = f"commit{'s' if count != 1 else ''}" + question = f"You have {count} unpushed {word}. Push to continue?" + + await client.session_update( + session_id=session.id, + update=_teleport_progress_update( + tool_call_id, + title="Push required", + text=question, + teleport_status="push_required", + unpushed_count=count, + branch_not_pushed=branch_not_pushed, + ), + ) + + response = await client.request_permission( + session_id=session.id, + tool_call=ToolCallUpdate( + tool_call_id=tool_call_id, + title=question, + kind="execute", + status="pending", + field_meta=_teleport_field_meta( + "push_required", + unpushed_count=count, + branch_not_pushed=branch_not_pushed, + ), + ), + options=[ + PermissionOption( + option_id=TELEPORT_PUSH_OPTION_ID, + name="Push and continue", + kind="allow_once", + ), + PermissionOption( + option_id=TELEPORT_CANCEL_OPTION_ID, name="Cancel", kind="reject_once" + ), + ], + ) + + if response.outcome.outcome != "selected": + return TeleportPushResponseEvent(approved=False) + + outcome = cast(AllowedOutcome, response.outcome) + return TeleportPushResponseEvent( + approved=outcome.option_id == TELEPORT_PUSH_OPTION_ID + ) + + +async def handle_teleport_command( + client: Client, session: AcpSessionLoop, message_id: str +) -> PromptResponse: + if not session.agent_loop.base_config.vibe_code_enabled: + return await _teleport_command_reply( + client, + session, + "Teleport is not available because Vibe Code is disabled.", + message_id, + field_meta=_teleport_field_meta("unavailable"), + ) + + last_user_message = next( + ( + msg + for msg in reversed(session.agent_loop.messages) + if msg.role == Role.user and not msg.injected + ), + None, + ) + has_resolvable_prompt = ( + last_user_message is not None + and isinstance(last_user_message.content, str) + and bool(last_user_message.content) + ) + if not has_resolvable_prompt: + send_teleport_early_failure_telemetry( + session.agent_loop.telemetry_client, + stage="no_history", + error_class="TeleportNoHistoryError", + nb_session_messages=len(session.agent_loop.messages[1:]), + ) + return await _teleport_command_reply( + client, + session, + "No conversation history to teleport.", + message_id, + field_meta=_teleport_field_meta("no_history"), + ) + + tool_call_id = str(uuid4()) + await client.session_update( + session_id=session.id, + update=ToolCallStart( + session_update="tool_call", + tool_call_id=tool_call_id, + title="Teleporting session to Vibe Code Web...", + kind="other", + status="in_progress", + content=[ + ContentToolCallContent( + type="content", + content=TextContentBlock( + type="text", text="Preparing workspace..." + ), + ) + ], + field_meta=_teleport_field_meta("starting"), + ), + ) + + final_url: str | None = None + try: + async with aclosing(session.agent_loop.teleport_to_vibe_code(None)) as events: + response: TeleportPushResponseEvent | None = None + while True: + try: + event = await events.asend(response) + except StopAsyncIteration: + break + response = None + + match event: + case TeleportCheckingGitEvent(): + await client.session_update( + session_id=session.id, + update=_teleport_progress_update( + tool_call_id, + title="Preparing workspace...", + text="Preparing workspace...", + teleport_status="preparing_workspace", + ), + ) + case TeleportPushRequiredEvent( + unpushed_count=count, branch_not_pushed=branch_not_pushed + ): + response = await _request_teleport_push_approval( + client, + session, + tool_call_id, + count=count, + branch_not_pushed=branch_not_pushed, + ) + case TeleportPushingEvent(): + await client.session_update( + session_id=session.id, + update=_teleport_progress_update( + tool_call_id, + title="Syncing with remote...", + text="Syncing with remote...", + teleport_status="syncing_remote", + ), + ) + case TeleportStartingWorkflowEvent(): + await client.session_update( + session_id=session.id, + update=_teleport_progress_update( + tool_call_id, + title="Starting Vibe Code Web session...", + text="Starting Vibe Code Web session...", + teleport_status="starting_workflow", + ), + ) + case TeleportCompleteEvent(url=url): + final_url = url + await client.session_update( + session_id=session.id, + update=_teleport_progress_update( + tool_call_id, + title="Teleported to Vibe Code Web", + status="completed", + text=f"Teleported to Vibe Code Web: {url}", + raw_output=url, + url=url, + teleport_status="completed", + ), + ) + except TeleportError as e: + await client.session_update( + session_id=session.id, + update=_teleport_progress_update( + tool_call_id, + title="Teleport failed", + status="failed", + text=str(e), + raw_output=str(e), + teleport_status="failed", + ), + ) + return PromptResponse( + stop_reason="end_turn", + user_message_id=message_id, + field_meta=_teleport_field_meta("failed"), + ) + + return PromptResponse( + stop_reason="end_turn", + user_message_id=message_id, + field_meta=_teleport_field_meta("completed", url=final_url), + ) diff --git a/vibe/acp/utils.py b/vibe/acp/utils.py index 36cd74b..ffabbb4 100644 --- a/vibe/acp/utils.py +++ b/vibe/acp/utils.py @@ -7,6 +7,7 @@ from acp.schema import ( AgentMessageChunk, AgentThoughtChunk, ContentToolCallContent, + Implementation, ModelInfo, PermissionOption, PermissionOptionKind, @@ -109,6 +110,10 @@ def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool: ) +def is_jetbrains_client(client_info: Implementation | None) -> bool: + return bool(client_info and client_info.name.startswith("JetBrains.")) + + def build_mode_state( profiles: list[AgentProfile], current_mode_id: str ) -> tuple[SessionModeState, SessionConfigOptionSelect]: diff --git a/vibe/cli/cli.py b/vibe/cli/cli.py index c8d0867..8dd7ac5 100644 --- a/vibe/cli/cli.py +++ b/vibe/cli/cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import asyncio from pathlib import Path import sys @@ -10,10 +11,16 @@ import tomli_w from vibe import __version__ from vibe.cli.textual_ui.app import StartupOptions, run_textual_ui +from vibe.cli.update_notifier import ( + FileSystemUpdateCacheRepository, + UpdateCacheRepository, + get_pending_update_from_cache, + mark_update_as_dismissed, +) from vibe.core.agent_loop import AgentLoop, TeleportError from vibe.core.config import MissingAPIKeyError, VibeConfig, load_dotenv_values from vibe.core.config.harness_files import get_harness_files_manager -from vibe.core.hooks.config import load_hooks_from_fs +from vibe.core.hooks.config import HookConfigResult, load_hooks_from_fs from vibe.core.logger import logger from vibe.core.paths import HISTORY_FILE from vibe.core.programmatic import run_programmatic @@ -26,6 +33,7 @@ from vibe.core.trusted_folders import find_trustable_files, trusted_folders_mana from vibe.core.types import LLMMessage, OutputFormat, Role from vibe.core.utils import ConversationLimitException from vibe.setup.onboarding import run_onboarding +from vibe.setup.update_prompt import UpdatePromptResult, ask_update_prompt def _build_cli_entrypoint_metadata() -> EntrypointMetadata: @@ -183,6 +191,94 @@ def _resume_previous_session( ) +def _run_programmatic_mode( + args: argparse.Namespace, + config: VibeConfig, + initial_agent_name: str, + hook_config_result: HookConfigResult, + loaded_session: tuple[list[LLMMessage], Path] | None, + stdin_prompt: str | None, +) -> None: + warn_if_workdir_trust_is_unset() + config.disabled_tools = [ + *config.disabled_tools, + "ask_user_question", + "exit_plan_mode", + ] + programmatic_prompt = args.prompt or stdin_prompt + if not programmatic_prompt: + print("Error: No prompt provided for programmatic mode", file=sys.stderr) + sys.exit(1) + output_format = OutputFormat(args.output if hasattr(args, "output") else "text") + + try: + final_response = run_programmatic( + config=config, + prompt=programmatic_prompt or "", + max_turns=args.max_turns, + max_price=args.max_price, + max_session_tokens=args.max_tokens, + output_format=output_format, + previous_messages=loaded_session[0] if loaded_session else None, + agent_name=initial_agent_name, + teleport=args.teleport and config.vibe_code_enabled, + headless=True, + hook_config_result=hook_config_result, + ) + if final_response: + print(final_response) + sys.exit(0) + except ConversationLimitException as e: + print(e, file=sys.stderr) + sys.exit(1) + except TeleportError as e: + print(f"Teleport error: {e}", file=sys.stderr) + sys.exit(1) + except (RuntimeError, ValueError) as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def _maybe_run_startup_update_prompt( + config: VibeConfig, repository: UpdateCacheRepository +) -> None: + if not config.enable_update_checks: + return + + try: + latest_version = asyncio.run( + get_pending_update_from_cache(repository, __version__) + ) + except OSError as exc: + logger.debug("Failed to read pending update from cache", exc_info=exc) + return + + if latest_version is None: + return + + result = ask_update_prompt(__version__, latest_version, theme=config.theme) + + match result: + case UpdatePromptResult.CONTINUE: + try: + asyncio.run(mark_update_as_dismissed(repository, latest_version)) + except OSError as exc: + logger.debug("Failed to persist dismissed update", exc_info=exc) + return + case UpdatePromptResult.QUIT: + sys.exit(0) + case UpdatePromptResult.UPDATED: + rprint( + f"[green]✔ Vibe was updated from {__version__} to " + f"{latest_version}.[/]\n Run [bold]vibe[/] to start using the " + "new version." + ) + sys.exit(0) + case UpdatePromptResult.UPDATE_FAILED: + rprint("[red]✗ Vibe could not be updated automatically.[/]") + sys.exit(1) + + def run_cli(args: argparse.Namespace) -> None: load_dotenv_values() bootstrap_config_files() @@ -194,6 +290,11 @@ def run_cli(args: argparse.Namespace) -> None: try: is_interactive = args.prompt is None config = load_config_or_exit(interactive=is_interactive) + update_cache_repository = FileSystemUpdateCacheRepository() + + if is_interactive: + _maybe_run_startup_update_prompt(config, update_cache_repository) + initial_agent_name = get_initial_agent_name(args, config) hook_config_result = load_hooks_from_fs(config) setup_tracing(config) @@ -204,50 +305,7 @@ def run_cli(args: argparse.Namespace) -> None: loaded_session = load_session(args, config) 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", - "exit_plan_mode", - ] - programmatic_prompt = args.prompt or stdin_prompt - if not programmatic_prompt: - print( - "Error: No prompt provided for programmatic mode", file=sys.stderr - ) - sys.exit(1) - output_format = OutputFormat( - args.output if hasattr(args, "output") else "text" - ) - - try: - final_response = run_programmatic( - config=config, - prompt=programmatic_prompt or "", - max_turns=args.max_turns, - max_price=args.max_price, - max_session_tokens=args.max_tokens, - output_format=output_format, - previous_messages=loaded_session[0] if loaded_session else None, - agent_name=initial_agent_name, - teleport=args.teleport and config.vibe_code_enabled, - headless=True, - hook_config_result=hook_config_result, - ) - if final_response: - print(final_response) - sys.exit(0) - except ConversationLimitException as e: - print(e, file=sys.stderr) - sys.exit(1) - except TeleportError as e: - print(f"Teleport error: {e}", file=sys.stderr) - sys.exit(1) - except (RuntimeError, ValueError) as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - else: + if is_interactive: try: agent_loop = AgentLoop( config, @@ -266,6 +324,7 @@ def run_cli(args: argparse.Namespace) -> None: run_textual_ui( agent_loop=agent_loop, + update_cache_repository=update_cache_repository, startup=StartupOptions( initial_prompt=args.initial_prompt or stdin_prompt, teleport_on_start=args.teleport, @@ -273,6 +332,15 @@ def run_cli(args: argparse.Namespace) -> None: is_resuming_session=loaded_session is not None, ), ) + else: + _run_programmatic_mode( + args=args, + config=config, + initial_agent_name=initial_agent_name, + hook_config_result=hook_config_result, + loaded_session=loaded_session, + stdin_prompt=stdin_prompt, + ) except (KeyboardInterrupt, EOFError): rprint("\n[dim]Bye![/]") diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index a792a3b..d487679 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -112,7 +112,6 @@ from vibe.cli.textual_ui.windowing import ( sync_backfill_state, ) from vibe.cli.update_notifier import ( - FileSystemUpdateCacheRepository, PyPIUpdateGateway, UpdateCacheRepository, UpdateError, @@ -122,7 +121,6 @@ from vibe.cli.update_notifier import ( mark_version_as_seen, should_show_whats_new, ) -from vibe.cli.update_notifier.update import do_update from vibe.cli.voice_manager import VoiceManager, VoiceManagerPort from vibe.cli.voice_manager.voice_manager_port import TranscribeState from vibe.cli.vscode_extension_promo import ( @@ -3270,48 +3268,19 @@ class VibeApp(App): # noqa: PLR0904 asyncio.create_task(self._check_update(), name="version-update-check") async def _check_update(self) -> None: - try: - if self._update_notifier is None or self._update_cache_repository is None: - return + if self._update_notifier is None or self._update_cache_repository is None: + return - update_availability = await get_update_if_available( + try: + await get_update_if_available( update_notifier=self._update_notifier, current_version=self._current_version, update_cache_repository=self._update_cache_repository, ) - except UpdateError as error: - self.notify( - error.message, - title="Update check failed", - severity="warning", - timeout=10, - ) - return + except UpdateError as exc: + logger.warning("Update check failed", exc_info=exc) except Exception as exc: - logger.debug("Version update check failed", exc_info=exc) - return - - if update_availability is None or not update_availability.should_notify: - return - - update_message_prefix = ( - f"{self._current_version} => {update_availability.latest_version}" - ) - - if self.config.enable_auto_update and await do_update(): - self.notify( - f"{update_message_prefix}\nVibe was updated successfully. Please restart to use the new version.", - title="Update successful", - severity="information", - timeout=float("inf"), - ) - return - - message = f"{update_message_prefix}\nPlease update mistral-vibe with your package manager" - - self.notify( - message, title="Update available", severity="information", timeout=10 - ) + logger.debug("Update check failed", exc_info=exc) def action_copy_selection(self) -> None: copied_text = copy_selection_to_clipboard(self, show_toast=False) @@ -3364,12 +3333,13 @@ class VibeApp(App): # noqa: PLR0904 def run_textual_ui( - agent_loop: AgentLoop, startup: StartupOptions | None = None + agent_loop: AgentLoop, + update_cache_repository: UpdateCacheRepository, + startup: StartupOptions | None = None, ) -> None: from vibe.cli.stderr_guard import stderr_guard update_notifier = PyPIUpdateGateway(project_name="mistral-vibe") - update_cache_repository = FileSystemUpdateCacheRepository() plan_offer_gateway = HttpWhoAmIGateway(base_url=agent_loop.config.console_base_url) vscode_extension_promo_repository = FileSystemVscodeExtensionPromoRepository() vscode_extension_promo = VscodeExtensionPromo( diff --git a/vibe/cli/update_notifier/__init__.py b/vibe/cli/update_notifier/__init__.py index 3e894a3..e47bdfb 100644 --- a/vibe/cli/update_notifier/__init__.py +++ b/vibe/cli/update_notifier/__init__.py @@ -19,7 +19,9 @@ from vibe.cli.update_notifier.ports.update_gateway import ( from vibe.cli.update_notifier.update import ( UpdateAvailability, UpdateError, + get_pending_update_from_cache, get_update_if_available, + mark_update_as_dismissed, ) from vibe.cli.update_notifier.whats_new import ( load_whats_new_content, @@ -40,8 +42,10 @@ __all__ = [ "UpdateGateway", "UpdateGatewayCause", "UpdateGatewayError", + "get_pending_update_from_cache", "get_update_if_available", "load_whats_new_content", + "mark_update_as_dismissed", "mark_version_as_seen", "should_show_whats_new", ] 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 b11d388..fc788cb 100644 --- a/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +++ b/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py @@ -19,12 +19,16 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository): self._base_path = Path(base_path) if base_path is not None else VIBE_HOME.path self._cache_file = self._base_path / "cache.toml" self._legacy_json = self._base_path / "update_cache.json" + self._cached: UpdateCache | None = None + self._loaded = False async def get(self) -> UpdateCache | None: + if self._loaded: + return self._cached data = await asyncio.to_thread(self._read_section) - if data is None: - return None - return self._parse(data) + self._cached = self._parse(data) if data is not None else None + self._loaded = True + return self._cached async def set(self, update_cache: UpdateCache) -> None: payload: dict[str, str | int] = { @@ -33,7 +37,11 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository): } if update_cache.seen_whats_new_version is not None: payload["seen_whats_new_version"] = update_cache.seen_whats_new_version + if update_cache.dismissed_version is not None: + payload["dismissed_version"] = update_cache.dismissed_version await asyncio.to_thread(write_cache, self._cache_file, _CACHE_SECTION, payload) + self._cached = update_cache + self._loaded = True def _read_section(self) -> dict | None: cache = read_cache(self._cache_file) @@ -58,20 +66,22 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository): latest_version = data.get("latest_version") stored_at_timestamp = data.get("stored_at_timestamp") seen_whats_new_version = data.get("seen_whats_new_version") + dismissed_version = data.get("dismissed_version") if not isinstance(latest_version, str) or not isinstance( stored_at_timestamp, int ): return None - if ( - not isinstance(seen_whats_new_version, str) - and seen_whats_new_version is not None - ): + if not isinstance(seen_whats_new_version, str): seen_whats_new_version = None + if not isinstance(dismissed_version, str): + dismissed_version = None + return UpdateCache( latest_version=latest_version, stored_at_timestamp=stored_at_timestamp, seen_whats_new_version=seen_whats_new_version, + dismissed_version=dismissed_version, ) diff --git a/vibe/cli/update_notifier/ports/update_cache_repository.py b/vibe/cli/update_notifier/ports/update_cache_repository.py index da0c1bf..f8e282e 100644 --- a/vibe/cli/update_notifier/ports/update_cache_repository.py +++ b/vibe/cli/update_notifier/ports/update_cache_repository.py @@ -9,6 +9,7 @@ class UpdateCache: latest_version: str stored_at_timestamp: int seen_whats_new_version: str | None = None + dismissed_version: str | None = None class UpdateCacheRepository(Protocol): diff --git a/vibe/cli/update_notifier/update.py b/vibe/cli/update_notifier/update.py index d4db614..3542635 100644 --- a/vibe/cli/update_notifier/update.py +++ b/vibe/cli/update_notifier/update.py @@ -2,7 +2,8 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from dataclasses import dataclass +from contextlib import suppress +from dataclasses import dataclass, replace import time from packaging.version import InvalidVersion, Version @@ -74,18 +75,56 @@ async def _write_update_cache( version: str, get_current_timestamp: Callable[[], int], ) -> None: + previous = await repository.get() + timestamp = get_current_timestamp() + if previous is None: + await repository.set( + UpdateCache(latest_version=version, stored_at_timestamp=timestamp) + ) + return await repository.set( - UpdateCache(latest_version=version, stored_at_timestamp=get_current_timestamp()) + replace(previous, latest_version=version, stored_at_timestamp=timestamp) ) +async def get_pending_update_from_cache( + repository: UpdateCacheRepository, current_version: str +) -> str | None: + current = _parse_version(current_version) + if current is None: + return None + + cache = await repository.get() + if cache is None: + return None + + latest = _parse_version(cache.latest_version) + if latest is None or latest <= current: + return None + + if cache.dismissed_version == cache.latest_version: + return None + + return cache.latest_version + + +async def mark_update_as_dismissed( + repository: UpdateCacheRepository, version: str +) -> None: + cache = await repository.get() + if cache is None: + return + await repository.set(replace(cache, dismissed_version=version)) + + async def get_update_if_available( update_notifier: UpdateGateway, current_version: str, update_cache_repository: UpdateCacheRepository, get_current_timestamp: Callable[[], int] = lambda: int(time.time()), ) -> UpdateAvailability | None: - if not (current := _parse_version(current_version)): + current = _parse_version(current_version) + if current is None: return None if update_cache := await update_cache_repository.get(): @@ -133,7 +172,24 @@ async def do_update() -> bool: stderr=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.DEVNULL, ) - await process.wait() + try: + await process.wait() + except asyncio.CancelledError: + await _terminate(process) + raise if process.returncode == 0: return True return False + + +async def _terminate(process: asyncio.subprocess.Process) -> None: + if process.returncode is not None: + return + with suppress(ProcessLookupError): + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=2.0) + except TimeoutError: + with suppress(ProcessLookupError): + process.kill() + await process.wait() diff --git a/vibe/cli/update_notifier/whats_new.py b/vibe/cli/update_notifier/whats_new.py index f44490b..ee5fe27 100644 --- a/vibe/cli/update_notifier/whats_new.py +++ b/vibe/cli/update_notifier/whats_new.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import replace import time from vibe import VIBE_ROOT @@ -40,11 +41,5 @@ async def mark_version_as_seen(version: str, repository: UpdateCacheRepository) seen_whats_new_version=version, ) ) - else: - await repository.set( - UpdateCache( - latest_version=cache.latest_version, - stored_at_timestamp=cache.stored_at_timestamp, - seen_whats_new_version=version, - ) - ) + return + await repository.set(replace(cache, seen_whats_new_version=version)) diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index d1d762d..f45b340 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -67,7 +67,10 @@ 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.build_metadata import ( + build_attachment_counts, + build_request_metadata, +) from vibe.core.telemetry.send import TelemetryClient from vibe.core.telemetry.types import ( EntrypointMetadata, @@ -748,6 +751,10 @@ class AgentLoop: # noqa: PLR0904 None, ) + def set_max_turns(self, max_turns: int) -> None: + self._max_turns = max_turns + self._setup_middleware() + def _setup_middleware(self) -> None: """Configure middleware pipeline for this conversation.""" self.middleware_pipeline.clear() @@ -1373,6 +1380,9 @@ class AgentLoop: # noqa: PLR0904 else 0, call_type=backend_metadata.call_type, message_id=backend_metadata.message_id, + attachment_counts=build_attachment_counts( + last_user_message, supports_images=active_model.supports_images + ), ) try: @@ -1436,6 +1446,9 @@ class AgentLoop: # noqa: PLR0904 else 0, call_type=backend_metadata.call_type, message_id=backend_metadata.message_id, + attachment_counts=build_attachment_counts( + last_user_message, supports_images=active_model.supports_images + ), ) try: diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py index ccacd5b..2d42b0f 100644 --- a/vibe/core/config/_settings.py +++ b/vibe/core/config/_settings.py @@ -531,7 +531,6 @@ class VibeConfig(BaseSettings): include_project_context: bool = True include_prompt_detail: bool = True enable_update_checks: bool = True - enable_auto_update: bool = True enable_notifications: bool = True enable_system_trust_store: bool = False api_timeout: float = DEFAULT_API_TIMEOUT diff --git a/vibe/core/skills/builtins/vibe.py b/vibe/core/skills/builtins/vibe.py index 9fd74a8..c72f36e 100644 --- a/vibe/core/skills/builtins/vibe.py +++ b/vibe/core/skills/builtins/vibe.py @@ -76,8 +76,7 @@ bypass_tool_permissions = false # Skip tool approval prompts system_prompt_id = "cli" # System prompt: "cli", "lean", or custom .md filename compaction_prompt_id = "compact" # Compaction prompt: built-in "compact" or custom .md filename enable_telemetry = true -enable_update_checks = true -enable_auto_update = true +enable_update_checks = true # Daily PyPI check; prompts on next launch when a newer release exists enable_notifications = true enable_system_trust_store = false # Use OS trust store for outbound HTTPS api_timeout = 720.0 # API request timeout in seconds diff --git a/vibe/core/telemetry/build_metadata.py b/vibe/core/telemetry/build_metadata.py index 014c7b6..d01b836 100644 --- a/vibe/core/telemetry/build_metadata.py +++ b/vibe/core/telemetry/build_metadata.py @@ -4,11 +4,13 @@ from typing import Any, cast from vibe.core.telemetry.types import ( AgentEntrypoint, + AttachmentKind, EntrypointMetadata, TelemetryBaseMetadata, TelemetryCallType, TelemetryRequestMetadata, ) +from vibe.core.types import LLMMessage def build_base_metadata( @@ -52,6 +54,17 @@ def build_request_metadata( ) +def build_attachment_counts( + message: LLMMessage | None, *, supports_images: bool +) -> dict[AttachmentKind, int]: + if message is None: + return {} + counts: dict[AttachmentKind, int] = {} + if supports_images and message.images: + counts[AttachmentKind.IMAGE] = len(message.images) + return counts + + def build_entrypoint_metadata( *, agent_entrypoint: AgentEntrypoint, diff --git a/vibe/core/telemetry/send.py b/vibe/core/telemetry/send.py index 975b5eb..3726a1d 100644 --- a/vibe/core/telemetry/send.py +++ b/vibe/core/telemetry/send.py @@ -15,6 +15,7 @@ from vibe.core.logger import logger from vibe.core.telemetry.build_metadata import build_base_metadata from vibe.core.telemetry.types import ( AgentEntrypoint, + AttachmentKind, EntrypointMetadata, TelemetryCallType, TeleportCompletedPayload, @@ -303,6 +304,7 @@ class TelemetryClient: nb_prompt_chars: int, call_type: TelemetryCallType, message_id: str | None = None, + attachment_counts: dict[AttachmentKind, int] | None = None, ) -> None: payload = { "model": model, @@ -312,6 +314,11 @@ class TelemetryClient: "call_source": "vibe_code", "call_type": call_type, "message_id": message_id, + "attachment_counts": { + kind.value: count + for kind, count in (attachment_counts or {}).items() + if count > 0 + }, } self.send_telemetry_event("vibe.request_sent", payload) diff --git a/vibe/core/telemetry/types.py b/vibe/core/telemetry/types.py index 7c37a50..9e25e4e 100644 --- a/vibe/core/telemetry/types.py +++ b/vibe/core/telemetry/types.py @@ -1,10 +1,15 @@ from __future__ import annotations +from enum import StrEnum from typing import Literal, TypedDict from pydantic import BaseModel +class AttachmentKind(StrEnum): + IMAGE = "image" + + class ClientMetadata(BaseModel): name: str version: str diff --git a/vibe/core/tools/builtins/prompts/bash.md b/vibe/core/tools/builtins/prompts/bash.md index d22fa7f..4156e53 100644 --- a/vibe/core/tools/builtins/prompts/bash.md +++ b/vibe/core/tools/builtins/prompts/bash.md @@ -17,7 +17,7 @@ Use the `bash` tool to run one-off shell commands. - `sed -n '100,200p' filename` → Use `read(file_path="filename", offset=100, limit=101)` - `less`, `more`, `vim`, `nano` → Use `read` with offset/limit for navigation - `echo "content" > file` → Use `write_file(path="file", content="content")` -- `echo "content" >> existing_file` → Read first, then use `search_replace` to append (write_file refuses to overwrite) +- `echo "content" >> existing_file` → Read first, then use `edit` to append (write_file refuses to overwrite) **Search Operations - DO NOT USE:** - `grep -r "pattern" .` → Use `grep(pattern="pattern", path=".")` diff --git a/vibe/core/tools/builtins/prompts/write_file.md b/vibe/core/tools/builtins/prompts/write_file.md index f4b8777..7817588 100644 --- a/vibe/core/tools/builtins/prompts/write_file.md +++ b/vibe/core/tools/builtins/prompts/write_file.md @@ -7,7 +7,7 @@ Use `write_file` to create a new file. **BEHAVIOR:** - `write_file` can ONLY create new files. -- If the file already exists, the tool returns an error. Use `search_replace` to edit existing files. +- If the file already exists, the tool returns an error. Use `edit` to modify existing files. - Parent directories are created automatically if they don't exist. **BEST PRACTICES:** diff --git a/vibe/core/tools/builtins/write_file.py b/vibe/core/tools/builtins/write_file.py index c7d23fc..bf8ed84 100644 --- a/vibe/core/tools/builtins/write_file.py +++ b/vibe/core/tools/builtins/write_file.py @@ -49,7 +49,7 @@ class WriteFile( ToolUIData[WriteFileArgs, WriteFileResult], ): description: ClassVar[str] = ( - "Create a UTF-8 file. Fails if the file already exists; use search_replace to edit." + "Create a UTF-8 file. Fails if the file already exists; use edit to modify." ) @classmethod @@ -115,7 +115,7 @@ class WriteFile( if file_path.exists(): raise ToolError( - f"File '{file_path}' already exists. Use search_replace to edit it." + f"File '{file_path}' already exists. Use edit to modify it." ) if self.config.create_parent_dirs: @@ -133,7 +133,7 @@ class WriteFile( await f.write(args.content) except FileExistsError as e: raise ToolError( - f"File '{file_path}' already exists. Use search_replace to edit it." + f"File '{file_path}' already exists. Use edit to modify it." ) from e except Exception as e: raise ToolError(f"Error writing {file_path}: {e}") from e diff --git a/vibe/setup/trusted_folders/trust_folder_dialog.py b/vibe/setup/trusted_folders/trust_folder_dialog.py index 02d1bae..fd0ae86 100644 --- a/vibe/setup/trusted_folders/trust_folder_dialog.py +++ b/vibe/setup/trusted_folders/trust_folder_dialog.py @@ -281,7 +281,7 @@ class TrustFolderApp(App): self.exit() def run_trust_dialog(self) -> TrustDecision | None: - self.run() + self.run(inline=True) if self._quit_without_saving: raise TrustDialogQuitException() return self._result diff --git a/vibe/setup/update_prompt/__init__.py b/vibe/setup/update_prompt/__init__.py new file mode 100644 index 0000000..c891893 --- /dev/null +++ b/vibe/setup/update_prompt/__init__.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from vibe.setup.update_prompt.update_prompt_dialog import ( + UpdatePromptResult, + ask_update_prompt, +) + +__all__ = ["UpdatePromptResult", "ask_update_prompt"] diff --git a/vibe/setup/update_prompt/update_prompt_dialog.py b/vibe/setup/update_prompt/update_prompt_dialog.py new file mode 100644 index 0000000..73fa985 --- /dev/null +++ b/vibe/setup/update_prompt/update_prompt_dialog.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import asyncio +from contextlib import suppress +from enum import StrEnum, auto +from typing import Any, ClassVar + +from textual import events +from textual.app import App, ComposeResult +from textual.binding import Binding, BindingType +from textual.containers import CenterMiddle, Horizontal +from textual.message import Message + +from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat +from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic +from vibe.cli.update_notifier.update import do_update +from vibe.core.logger import logger + + +class UpdatePromptResult(StrEnum): + CONTINUE = auto() + UPDATED = auto() + UPDATE_FAILED = auto() + QUIT = auto() + + +class UpdateChoice(StrEnum): + UPDATE = auto() + CONTINUE = auto() + + +_CHOICE_LABELS: dict[UpdateChoice, str] = { + UpdateChoice.UPDATE: "Update now", + UpdateChoice.CONTINUE: "Continue with current version", +} + + +class UpdatePromptDialog(CenterMiddle): + can_focus = True + can_focus_children = True + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("left", "move_left", "Left", show=False), + Binding("right", "move_right", "Right", show=False), + Binding("enter", "select", "Select", show=False), + ] + + class Selected(Message): + def __init__(self, choice: UpdateChoice) -> None: + super().__init__() + self.choice = choice + + class UpdateFinished(Message): + def __init__(self, succeeded: bool) -> None: + super().__init__() + self.succeeded = succeeded + + def __init__( + self, current_version: str, latest_version: str, **kwargs: Any + ) -> None: + super().__init__(**kwargs) + self.current_version = current_version + self.latest_version = latest_version + self.selected: UpdateChoice = UpdateChoice.UPDATE + self._option_widgets: dict[UpdateChoice, NoMarkupStatic] = {} + self._is_updating = False + + def compose(self) -> ComposeResult: + with CenterMiddle(id="update-dialog"): + yield NoMarkupStatic( + "A new Vibe release is available", id="update-dialog-title" + ) + yield NoMarkupStatic( + f"{self.current_version} → {self.latest_version}", + id="update-dialog-version", + ) + + with Horizontal(id="update-options-container"): + for choice in UpdateChoice: + widget = NoMarkupStatic( + f" {_CHOICE_LABELS[choice]}", classes="update-option" + ) + self._option_widgets[choice] = widget + yield widget + + yield NoMarkupStatic( + "← → navigate Enter select", classes="update-dialog-help" + ) + + yield PetitChat(id="update-dialog-spinner") + yield NoMarkupStatic("Updating mistral-vibe…", id="update-dialog-status") + + async def on_mount(self) -> None: + spinner = self.query_one("#update-dialog-spinner", PetitChat) + spinner.display = False + status = self.query_one("#update-dialog-status", NoMarkupStatic) + status.display = False + self._refresh_options() + self.focus() + + def _refresh_options(self) -> None: + for choice in UpdateChoice: + widget = self._option_widgets[choice] + cursor = "› " if choice == self.selected else " " + widget.update(f"{cursor}{_CHOICE_LABELS[choice]}") + widget.remove_class("update-option--active") + widget.remove_class("update-option--inactive") + widget.add_class( + "update-option--active" + if choice == self.selected + else "update-option--inactive" + ) + + def _move(self, delta: int) -> None: + if self._is_updating: + return + choices = list(UpdateChoice) + idx = (choices.index(self.selected) + delta) % len(choices) + self.selected = choices[idx] + self._refresh_options() + + def action_move_left(self) -> None: + self._move(-1) + + def action_move_right(self) -> None: + self._move(1) + + def action_select(self) -> None: + if self._is_updating: + return + self.post_message(self.Selected(self.selected)) + + async def enter_updating_state(self) -> None: + self._is_updating = True + + for widget in self._option_widgets.values(): + widget.display = False + self.query_one("#update-options-container", Horizontal).display = False + self.query_one(".update-dialog-help", NoMarkupStatic).display = False + + self.query_one("#update-dialog-spinner", PetitChat).display = True + self.query_one("#update-dialog-status", NoMarkupStatic).display = True + + try: + succeeded = await do_update() + except Exception as exc: + logger.warning("do_update raised unexpectedly", exc_info=exc) + succeeded = False + self.post_message(self.UpdateFinished(succeeded=succeeded)) + + def on_blur(self, _: events.Blur) -> None: + if self._is_updating: + return + self.call_after_refresh(self.focus) + + +class UpdatePromptApp(App[UpdatePromptResult]): + CSS_PATH = "update_prompt_dialog.tcss" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("ctrl+q", "quit_prompt", "Quit", show=False, priority=True), + Binding("ctrl+c", "quit_prompt", "Quit", show=False, priority=True), + ] + + def __init__( + self, + current_version: str, + latest_version: str, + theme: str | None = None, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self.current_version = current_version + self.latest_version = latest_version + self._theme_name = theme + self._dialog: UpdatePromptDialog | None = None + self._update_task: asyncio.Task[None] | None = None + + def on_mount(self) -> None: + if self._theme_name is not None: + self.theme = self._theme_name + + def compose(self) -> ComposeResult: + self._dialog = UpdatePromptDialog(self.current_version, self.latest_version) + yield self._dialog + + async def action_quit_prompt(self) -> None: + if self._update_task is not None and not self._update_task.done(): + self._update_task.cancel() + with suppress(asyncio.CancelledError, Exception): + await self._update_task + self.exit(UpdatePromptResult.QUIT) + + def on_update_prompt_dialog_selected( + self, message: UpdatePromptDialog.Selected + ) -> None: + match message.choice: + case UpdateChoice.UPDATE: + if self._dialog is None or self._update_task is not None: + return + self._update_task = asyncio.create_task( + self._dialog.enter_updating_state() + ) + case UpdateChoice.CONTINUE: + self.exit(UpdatePromptResult.CONTINUE) + + def on_update_prompt_dialog_update_finished( + self, message: UpdatePromptDialog.UpdateFinished + ) -> None: + self.exit( + UpdatePromptResult.UPDATED + if message.succeeded + else UpdatePromptResult.UPDATE_FAILED + ) + + +def ask_update_prompt( + current_version: str, latest_version: str, theme: str | None = None +) -> UpdatePromptResult: + app = UpdatePromptApp(current_version, latest_version, theme=theme) + return app.run(inline=True) or UpdatePromptResult.CONTINUE diff --git a/vibe/setup/update_prompt/update_prompt_dialog.tcss b/vibe/setup/update_prompt/update_prompt_dialog.tcss new file mode 100644 index 0000000..6a086d8 --- /dev/null +++ b/vibe/setup/update_prompt/update_prompt_dialog.tcss @@ -0,0 +1,85 @@ +Screen { + align: center middle; + background: transparent 80%; +} + +#update-dialog { + max-width: 70; + border: round $border-blurred; + background: transparent; + height: auto; + padding: 1; + padding-left: 5; + padding-right: 5; +} + +#update-dialog-title { + width: 100%; + height: auto; + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; +} + +#update-dialog-version { + width: 100%; + height: auto; + color: $foreground; + text-align: center; + margin-bottom: 1; +} + +#update-options-container { + width: 100%; + height: auto; + align: center middle; + margin-bottom: 1; +} + +.update-option { + height: auto; + width: auto; + color: $foreground; + margin: 0 3; + content-align: center middle; +} + +.update-option--active { + color: $foreground; + text-style: bold; +} + +.update-option--inactive { + color: $foreground; +} + +.update-dialog-help { + width: 100%; + height: auto; + color: $text-muted; + text-align: center; + + &:ansi { + text-style: dim; + } +} + +#update-dialog-spinner { + width: 100%; + height: auto; + margin-bottom: 1; + align: center middle; +} + +.petit-chat { + color: $foreground; + width: 12; +} + +#update-dialog-status { + width: 100%; + height: auto; + color: $foreground; + text-align: center; +} diff --git a/vibe/whats_new.md b/vibe/whats_new.md index 2ccb013..02a83ff 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -1,4 +1,3 @@ -# What's new in v2.14.0 +# What's new in v2.14.1 -- **Image attachments**: Drop an image into the chat input — or `@`-mention it — and send it to vision-capable models. -- **New read and edit tools**: `read` and `edit` replace `read_file` and `search_replace`. Your config has been migrated to these new tool names. +- **Update prompt at startup**: Vibe now asks you to install a pending update before starting the session, instead of just notifying you.