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

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