Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
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 <pierre.rossines@mistral.ai>
Co-authored-by: Stanislas <stanislas.lange@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-06-08 14:59:41 +02:00 committed by GitHub
parent 3f8487f761
commit 702d0f412e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 2446 additions and 502 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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"

View file

@ -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 = [

View file

@ -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",
}

View file

@ -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 = [

View file

@ -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

View file

@ -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()

View file

@ -54,7 +54,6 @@ def get_base_config() -> dict[str, Any]:
"alias": "devstral-latest",
}
],
"enable_auto_update": False,
"enable_telemetry": False,
}

View file

@ -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) == {}

View file

@ -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]]

View file

@ -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]]",

0
tests/setup/__init__.py Normal file
View file

View file

@ -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()

View file

@ -1,204 +0,0 @@
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #ff8205;font-weight: bold }
.terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #ff8205 }
.terminal-r5 { fill: #98729f;font-weight: bold }
.terminal-r6 { fill: #98a84b }
.terminal-r7 { fill: #98a84b;font-weight: bold }
.terminal-r8 { fill: #868887 }
</style>
<defs>
<clipPath id="terminal-clip-terminal">
<rect x="0" y="0" width="1463.0" height="877.4" />
</clipPath>
<clipPath id="terminal-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-1">
<rect x="0" y="25.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-2">
<rect x="0" y="50.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-3">
<rect x="0" y="74.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-4">
<rect x="0" y="99.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-5">
<rect x="0" y="123.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-6">
<rect x="0" y="147.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-7">
<rect x="0" y="172.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-8">
<rect x="0" y="196.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-9">
<rect x="0" y="221.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-10">
<rect x="0" y="245.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-11">
<rect x="0" y="269.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-12">
<rect x="0" y="294.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-13">
<rect x="0" y="318.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-14">
<rect x="0" y="343.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-15">
<rect x="0" y="367.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-16">
<rect x="0" y="391.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-17">
<rect x="0" y="416.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-18">
<rect x="0" y="440.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-19">
<rect x="0" y="465.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-20">
<rect x="0" y="489.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-21">
<rect x="0" y="513.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-22">
<rect x="0" y="538.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-23">
<rect x="0" y="562.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-24">
<rect x="0" y="587.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-25">
<rect x="0" y="611.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-26">
<rect x="0" y="635.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-27">
<rect x="0" y="660.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-28">
<rect x="0" y="684.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-29">
<rect x="0" y="709.1" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-30">
<rect x="0" y="733.5" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-31">
<rect x="0" y="757.9" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-32">
<rect x="0" y="782.3" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-33">
<rect x="0" y="806.7" width="1464" height="24.65"/>
</clipPath>
<clipPath id="terminal-line-34">
<rect x="0" y="831.1" width="1464" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">SnapshotTestAppWithUpdate</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
<rect fill="#4b4e55" x="719.8" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="732" y="611.5" width="707.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="719.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="732" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="744.2" y="635.9" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="939.4" y="635.9" width="500.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="719.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="732" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="744.2" y="660.3" width="207.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="951.6" y="660.3" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="719.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="732" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="744.2" y="684.7" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1378.6" y="684.7" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="719.8" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="732" y="709.1" width="707.6" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-matrix">
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral&#160;Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)">&#160;v0.0.0&#160;·&#160;</text><text class="terminal-r3" x="439.2" y="508" textLength="244" clip-path="url(#terminal-line-20)">devstral-latest[off]</text><text class="terminal-r1" x="683.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)">&#160;·&#160;[Subscription]&#160;Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)">&#160;⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1&#160;model&#160;·&#160;0&#160;MCP&#160;servers&#160;·&#160;0&#160;skills</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">&#160;&#160;⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type&#160;</text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)">&#160;for&#160;more&#160;information</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r5" x="24.4" y="605.6" textLength="122" clip-path="url(#terminal-line-24)">What&#x27;s&#160;New</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="24.4" y="630" textLength="134.2" clip-path="url(#terminal-line-25)">&#160;Feature&#160;1</text><text class="terminal-r6" x="719.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="24.4" y="654.4" textLength="134.2" clip-path="url(#terminal-line-26)">&#160;Feature&#160;2</text><text class="terminal-r6" x="719.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r7" x="744.2" y="654.4" textLength="195.2" clip-path="url(#terminal-line-26)">Update&#160;available</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r6" x="719.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="744.2" y="678.8" textLength="207.4" clip-path="url(#terminal-line-27)">1.0.4&#160;=&gt;&#160;1000.2.0</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r6" x="719.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"></text><text class="terminal-r1" x="744.2" y="703.2" textLength="634.4" clip-path="url(#terminal-line-28)">Please&#160;update&#160;mistral-vibe&#160;with&#160;your&#160;package&#160;manager</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r6" x="719.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"></text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
</text><text class="terminal-r1" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">──────────────────────────────────────────────────────────────────────────────────────────────────────────────&#160;default&#160;</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
</text><text class="terminal-r2" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">&gt;</text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
</text><text class="terminal-r1" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
</text><text class="terminal-r8" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r8" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0%&#160;of&#160;200k&#160;tokens</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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"
)

View file

@ -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"

2
uv.lock generated
View file

@ -825,7 +825,7 @@ wheels = [
[[package]]
name = "mistral-vibe"
version = "2.14.0"
version = "2.14.1"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },

View file

@ -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"

View file

@ -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:

View file

@ -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"]

View file

@ -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",

334
vibe/acp/teleport.py Normal file
View file

@ -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),
)

View file

@ -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]:

View file

@ -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![/]")

View file

@ -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(

View file

@ -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",
]

View file

@ -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,
)

View file

@ -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):

View file

@ -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()

View file

@ -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))

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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=".")`

View file

@ -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:**

View file

@ -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

View file

@ -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

View file

@ -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"]

View file

@ -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

View file

@ -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;
}

View file

@ -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.