Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai>
Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-authored-by: Clément Siriex <clement.sirieix@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-authored-by: Nicolas Karolak <nicolas@karolak.fr>
This commit is contained in:
Mathias Gesbert 2026-02-11 18:17:30 +01:00 committed by GitHub
parent 9809cfc831
commit 51fecc67d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
176 changed files with 8652 additions and 4451 deletions

View file

@ -1,6 +1,8 @@
from __future__ import annotations
from collections.abc import Generator
import logging
from os import environ
import pytest
@ -9,8 +11,23 @@ from vibe.cli.plan_offer.decide_plan_offer import (
PlanOfferAction,
PlanType,
decide_plan_offer,
resolve_api_key_for_plan,
)
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
from vibe.core.config import Backend, ProviderConfig
@pytest.fixture
def mistral_api_key_env() -> Generator[str, None, None]:
original_value = environ.get("MISTRAL_API_KEY")
test_api_key = "test_mistral_api_key"
environ["MISTRAL_API_KEY"] = test_api_key
yield test_api_key
if original_value is not None:
environ["MISTRAL_API_KEY"] = original_value
else:
del environ["MISTRAL_API_KEY"]
@pytest.mark.asyncio
@ -115,3 +132,51 @@ async def test_proposes_none_and_logs_warning_when_gateway_error_occurs(
assert plan_type is PlanType.UNKNOWN
assert gateway.calls == ["api-key"]
assert "Failed to fetch plan status." in caplog.text
def test_resolve_api_key_for_plan_with_mistral_backend(
mistral_api_key_env: str,
) -> None:
test_api_key = mistral_api_key_env
provider = ProviderConfig(
name="test_mistral",
api_base="https://api.mistral.ai",
backend=Backend.MISTRAL,
api_key_env_var="MISTRAL_API_KEY",
)
result = resolve_api_key_for_plan(provider)
assert result == test_api_key
def test_resolve_api_key_for_plan_with_non_mistral_backend(
mistral_api_key_env: str,
) -> None:
provider = ProviderConfig(
name="test_generic",
api_base="https://api.generic.ai",
backend=Backend.GENERIC,
api_key_env_var="GENERIC_API_KEY",
)
result = resolve_api_key_for_plan(provider)
assert result == mistral_api_key_env
def test_resolve_api_key_for_plan_with_missing_env_var() -> None:
previous_api_key = environ["MISTRAL_API_KEY"]
del environ["MISTRAL_API_KEY"]
provider = ProviderConfig(
name="test_mistral",
api_base="https://api.mistral.ai",
backend=Backend.MISTRAL,
api_key_env_var="MISTRAL_API_KEY",
)
result = resolve_api_key_for_plan(provider)
assert result is None
if previous_api_key is not None:
environ["MISTRAL_API_KEY"] = previous_api_key

View file

@ -1,63 +0,0 @@
from __future__ import annotations
import pytest
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from tests.stubs.fake_backend import FakeBackend
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.messages import PlanOfferMessage
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
def _make_app(gateway: FakeWhoAmIGateway, config: VibeConfig | None = None) -> VibeApp:
config = config or VibeConfig(
session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False
)
agent_loop = AgentLoop(config=config, backend=FakeBackend())
return VibeApp(agent_loop=agent_loop, plan_offer_gateway=gateway)
@pytest.mark.asyncio
async def test_app_shows_upgrade_offer_in_plan_offer_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "api-key")
gateway = FakeWhoAmIGateway(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=True,
prompt_switching_to_pro_plan=False,
)
)
app = _make_app(gateway)
async with app.run_test() as pilot:
await pilot.pause(0.1)
offer = app.query_one(PlanOfferMessage)
assert "Upgrade to" in offer.get_text()
assert "Pro" in offer.get_text()
assert gateway.calls == ["api-key"]
@pytest.mark.asyncio
async def test_app_shows_switch_to_pro_key_offer_in_plan_offer_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "api-key")
gateway = FakeWhoAmIGateway(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=False,
prompt_switching_to_pro_plan=True,
)
)
app = _make_app(gateway)
async with app.run_test() as pilot:
await pilot.pause(0.1)
offer = app.query_one(PlanOfferMessage)
assert "Switch to your" in offer.get_text()
assert "Pro API key" in offer.get_text()
assert gateway.calls == ["api-key"]

View file

@ -0,0 +1,114 @@
from __future__ import annotations
import pytest
from vibe.cli.textual_ui.widgets.braille_renderer import (
_braille_char_from_dot_indices,
_braille_dot_index,
render_braille,
)
class TestBrailleDotIndex:
"""Tests for _braille_dot_index(x, y)."""
def test_dot_positions_per_docstring(self) -> None:
# Layout from docstring: x in {0,1}, y in {0,1,2,3}
# -x-> | 1 4 | 2 5 | 3 6 | V 7 8
assert _braille_dot_index(0, 0) == 1
assert _braille_dot_index(1, 0) == 4
assert _braille_dot_index(0, 1) == 2
assert _braille_dot_index(1, 1) == 5
assert _braille_dot_index(0, 2) == 3
assert _braille_dot_index(1, 2) == 6
assert _braille_dot_index(0, 3) == 7
assert _braille_dot_index(1, 3) == 8
@pytest.mark.parametrize("x", [0, 1])
@pytest.mark.parametrize("y", [0, 1, 2, 3])
def test_all_indices_in_range_one_to_eight(self, x: int, y: int) -> None:
idx = _braille_dot_index(x, y)
assert 1 <= idx <= 8
class TestBrailleCharFromDotIndices:
"""Tests for _braille_char_from_dot_indices."""
def test_empty_indices_returns_space(self) -> None:
assert _braille_char_from_dot_indices([]) == " "
def test_single_dot_one_returns_braille_char(self) -> None:
# U+2800 is empty, +1 for dot 1 = U+2801
char = _braille_char_from_dot_indices([1])
assert char == ""
def test_all_dots_returns_full_cell(self) -> None:
char = _braille_char_from_dot_indices([1, 2, 3, 4, 5, 6, 7, 8])
# Full block: 0x2800 + (2^8 - 1) = 0x28FF
assert char == ""
def test_invalid_index_below_one_raises(self) -> None:
with pytest.raises(ValueError, match="Invalid braille dot indices"):
_braille_char_from_dot_indices([0])
def test_invalid_index_above_eight_raises(self) -> None:
with pytest.raises(ValueError, match="Invalid braille dot indices"):
_braille_char_from_dot_indices([9])
def test_order_of_indices_does_not_matter(self) -> None:
a = _braille_char_from_dot_indices([1, 2, 3])
b = _braille_char_from_dot_indices([3, 1, 2])
assert a == b
class TestRenderBraille:
"""Tests for render_braille(dot_coords, width, height)."""
def test_empty_coords_produces_blank_grid(self) -> None:
result = render_braille([], width=4, height=2)
assert result == " "
def test_origin_one_dot(self) -> None:
# (0, 0) -> first cell, dot 1
result = render_braille([0], width=2, height=4)
lines = result.split("\n")
assert len(lines) == 1
assert lines[0][0] == ""
assert lines[0].strip() == ""
def test_output_dimensions_match_ceiled_width_and_height(self) -> None:
result = render_braille([], width=3, height=7)
lines = result.split("\n")
assert len(lines) == 2
assert len(lines[0]) == 2
def test_multiple_dots_in_same_cell_combine(self) -> None:
# Two dots in first cell: (0,0) and (1,0) -> indices 1 and 4
result = render_braille([0, 1], width=4, height=4)
lines = result.split("\n")
assert len(lines) == 1
assert lines[0][0] == ""
def test_dots_in_different_cells(self) -> None:
# First cell (0,0), second cell (2,0)
result = render_braille([0, 2], width=4, height=4)
lines = result.split("\n")
assert len(lines) == 1
assert len(lines[0]) == 2
assert lines[0][0] == ""
assert lines[0][1] == ""
def test_multiple_rows(self) -> None:
# Dot at (0,0) and (0,4) -> two rows
result = render_braille([0, 4j], width=2, height=8)
lines = result.split("\n")
assert len(lines) == 2
assert lines[0][0] == ""
assert lines[1][0] == ""
def test_accepts_complex_coords(self) -> None:
result = render_braille([1 + 2j], width=4, height=4)
lines = result.split("\n")
assert len(lines) == 1
# x=1,y=2 -> first cell (0,0), sub_x=1, sub_y=2 -> dot index 6
assert lines[0][0] == _braille_char_from_dot_indices([6])

View file

@ -5,17 +5,10 @@ from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, mock_open, patch
import pyperclip
import pytest
from textual.app import App
from vibe.cli.clipboard import (
_copy_osc52,
_copy_wayland_clipboard,
_copy_x11_clipboard,
_get_copy_fns,
copy_selection_to_clipboard,
)
from vibe.cli.clipboard import _copy_osc52, copy_selection_to_clipboard
class MockWidget:
@ -42,7 +35,6 @@ def mock_app() -> App:
app = MagicMock(spec=App)
app.query = MagicMock(return_value=[])
app.notify = MagicMock()
app.copy_to_clipboard = MagicMock()
return cast(App, app)
@ -86,21 +78,18 @@ def test_copy_selection_to_clipboard_no_notification(
mock_app.notify.assert_not_called()
@patch("vibe.cli.clipboard._get_copy_fns")
@patch("vibe.cli.clipboard._copy_osc52")
def test_copy_selection_to_clipboard_success(
mock_get_copy_fns: MagicMock, mock_app: MagicMock
mock_copy_osc52: MagicMock, mock_app: MagicMock
) -> None:
widget = MockWidget(
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
)
mock_app.query.return_value = [widget]
mock_copy_fn = MagicMock()
mock_get_copy_fns.return_value = [mock_copy_fn]
copy_selection_to_clipboard(mock_app)
mock_copy_fn.assert_called_once_with("selected text")
mock_copy_osc52.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
'"selected text" copied to clipboard',
severity="information",
@ -109,54 +98,22 @@ def test_copy_selection_to_clipboard_success(
)
@patch("vibe.cli.clipboard._get_copy_fns")
def test_copy_selection_to_clipboard_tries_all(
mock_get_copy_fns: MagicMock, mock_app: MagicMock
@patch("vibe.cli.clipboard._copy_osc52")
def test_copy_selection_to_clipboard_failure(
mock_copy_osc52: MagicMock, mock_app: MagicMock
) -> None:
widget = MockWidget(
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
)
mock_app.query.return_value = [widget]
fn_1 = MagicMock(side_effect=Exception("failed"))
fn_2 = MagicMock()
fn_3 = MagicMock()
mock_get_copy_fns.return_value = [fn_1, fn_2, fn_3]
mock_copy_osc52.side_effect = Exception("OSC52 failed")
copy_selection_to_clipboard(mock_app)
fn_1.assert_called_once_with("selected text")
fn_2.assert_called_once_with("selected text")
fn_3.assert_called_once_with("selected text")
mock_copy_osc52.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
'"selected text" copied to clipboard',
severity="information",
timeout=2,
markup=False,
)
@patch("vibe.cli.clipboard._get_copy_fns")
def test_copy_selection_to_clipboard_all_methods_fail(
mock_get_copy_fns: MagicMock, mock_app: MagicMock
) -> None:
widget = MockWidget(
text_selection=SimpleNamespace(), get_selection_result=("selected text", None)
)
mock_app.query.return_value = [widget]
failing_fn1 = MagicMock(side_effect=Exception("failed 1"))
failing_fn2 = MagicMock(side_effect=Exception("failed 2"))
failing_fn3 = MagicMock(side_effect=Exception("failed 3"))
mock_get_copy_fns.return_value = [failing_fn1, failing_fn2, failing_fn3]
copy_selection_to_clipboard(mock_app)
failing_fn1.assert_called_once_with("selected text")
failing_fn2.assert_called_once_with("selected text")
failing_fn3.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
"Failed to copy - no clipboard method available", severity="warning", timeout=3
"Failed to copy - clipboard not available", severity="warning", timeout=3
)
@ -171,14 +128,12 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No
widget3 = MockWidget(text_selection=None)
mock_app.query.return_value = [widget1, widget2, widget3]
with patch("vibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns:
mock_copy_fn = MagicMock()
mock_get_copy_fns.return_value = [mock_copy_fn]
with patch("vibe.cli.clipboard._copy_osc52") as mock_copy_osc52:
copy_selection_to_clipboard(mock_app)
mock_copy_fn.assert_called_once_with("first selection\nsecond selection")
mock_copy_osc52.assert_called_once_with("first selection\nsecond selection")
mock_app.notify.assert_called_once_with(
'"first selectionsecond selection" copied to clipboard',
'"first selection\u23cesecond selection" copied to clipboard',
severity="information",
timeout=2,
markup=False,
@ -192,12 +147,10 @@ def test_copy_selection_to_clipboard_preview_shortening(mock_app: MagicMock) ->
)
mock_app.query.return_value = [widget]
with patch("vibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns:
mock_copy_fn = MagicMock()
mock_get_copy_fns.return_value = [mock_copy_fn]
with patch("vibe.cli.clipboard._copy_osc52") as mock_copy_osc52:
copy_selection_to_clipboard(mock_app)
mock_copy_fn.assert_called_once_with(long_text)
mock_copy_osc52.assert_called_once_with(long_text)
notification_call = mock_app.notify.call_args
assert notification_call is not None
assert '"' in notification_call[0][0]
@ -210,7 +163,7 @@ def test_copy_osc52_writes_correct_sequence(
mock_file: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("TMUX", raising=False)
test_text = "héllo wörld 🎉"
test_text = "hello world"
_copy_osc52(test_text)
@ -237,109 +190,16 @@ def test_copy_osc52_with_tmux(
handle.write.assert_called_once_with(expected_seq)
@patch("vibe.cli.clipboard.subprocess.run")
def test_copy_x11_clipboard(mock_subprocess: MagicMock) -> None:
test_text = "test text"
_copy_x11_clipboard(test_text)
mock_subprocess.assert_called_once_with(
["xclip", "-selection", "clipboard"],
input=test_text.encode("utf-8"),
check=True,
)
@patch("vibe.cli.clipboard.subprocess.run")
def test_copy_wayland_clipboard(mock_subprocess: MagicMock) -> None:
test_text = "test text"
_copy_wayland_clipboard(test_text)
mock_subprocess.assert_called_once_with(
["wl-copy"], input=test_text.encode("utf-8"), check=True
)
@patch("vibe.cli.clipboard.shutil.which")
def test_get_copy_fns_no_system_tools(mock_which: MagicMock, mock_app: App) -> None:
mock_which.return_value = None
copy_fns = _get_copy_fns(mock_app)
assert len(copy_fns) == 3
assert copy_fns[0] == _copy_osc52
assert copy_fns[1] == pyperclip.copy
assert copy_fns[2] == mock_app.copy_to_clipboard
@patch("vibe.cli.clipboard.platform.system")
@patch("vibe.cli.clipboard.shutil.which")
def test_get_copy_fns_with_xclip(
mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
@patch("builtins.open", new_callable=mock_open)
def test_copy_osc52_unicode(
mock_file: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_platform_system.return_value = "Linux"
monkeypatch.delenv("TMUX", raising=False)
test_text = "hello world"
def which_side_effect(cmd: str) -> str | None:
return "/usr/bin/xclip" if cmd == "xclip" else None
_copy_osc52(test_text)
mock_which.side_effect = which_side_effect
copy_fns = _get_copy_fns(mock_app)
assert len(copy_fns) == 4
assert copy_fns[0] == _copy_x11_clipboard
assert copy_fns[1] == _copy_osc52
assert copy_fns[2] == pyperclip.copy
assert copy_fns[3] == mock_app.copy_to_clipboard
@patch("vibe.cli.clipboard.platform.system")
@patch("vibe.cli.clipboard.shutil.which")
def test_get_copy_fns_with_wl_copy(
mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
) -> None:
mock_platform_system.return_value = "Linux"
def which_side_effect(cmd: str) -> str | None:
return "/usr/bin/wl-copy" if cmd == "wl-copy" else None
mock_which.side_effect = which_side_effect
copy_fns = _get_copy_fns(mock_app)
assert len(copy_fns) == 4
assert copy_fns[0] == _copy_wayland_clipboard
assert copy_fns[1] == _copy_osc52
assert copy_fns[2] == pyperclip.copy
assert copy_fns[3] == mock_app.copy_to_clipboard
@patch("vibe.cli.clipboard.platform.system")
@patch("vibe.cli.clipboard.shutil.which")
def test_get_copy_fns_with_both_system_tools(
mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
) -> None:
mock_platform_system.return_value = "Linux"
def which_side_effect(cmd: str) -> str | None:
match cmd:
case "wl-copy":
return "/usr/bin/wl-copy"
case "xclip":
return "/usr/bin/xclip"
case _:
return None
mock_which.side_effect = which_side_effect
copy_fns = _get_copy_fns(mock_app)
assert len(copy_fns) == 5
# xclip is checked last, so it's added last and ends up first in the list
assert copy_fns[0] == _copy_x11_clipboard
# wl-copy is checked first, so it's added before xclip
assert copy_fns[1] == _copy_wayland_clipboard
assert copy_fns[2] == _copy_osc52
assert copy_fns[3] == pyperclip.copy
assert copy_fns[4] == mock_app.copy_to_clipboard
encoded = base64.b64encode(test_text.encode("utf-8")).decode("ascii")
expected_seq = f"\033]52;c;{encoded}\a"
handle = mock_file()
handle.write.assert_called_once_with(expected_seq)

View file

@ -0,0 +1,59 @@
from __future__ import annotations
from unittest.mock import patch
import pytest
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
@pytest.mark.asyncio
async def test_ctrl_y_triggers_copy_selection() -> None:
"""Test that ctrl+y keybinding triggers copy_selection_to_clipboard."""
app = BaseSnapshotTestApp()
with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy:
async with app.run_test() as pilot:
await pilot.press("ctrl+y")
mock_copy.assert_called_once_with(app, show_toast=False)
@pytest.mark.asyncio
async def test_ctrl_shift_c_triggers_copy_selection() -> None:
"""Test that ctrl+shift+c keybinding triggers copy_selection_to_clipboard."""
app = BaseSnapshotTestApp()
with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy:
async with app.run_test() as pilot:
await pilot.press("ctrl+shift+c")
mock_copy.assert_called_once_with(app, show_toast=False)
@pytest.mark.asyncio
async def test_mouse_up_respects_autocopy_config_enabled() -> None:
"""Test that mouse up copies when autocopy_to_clipboard is True."""
from tests.snapshots.base_snapshot_test_app import default_config
config = default_config()
config.autocopy_to_clipboard = True
app = BaseSnapshotTestApp(config=config)
with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy:
async with app.run_test() as pilot:
await pilot.click()
mock_copy.assert_called_once_with(app, show_toast=True)
@pytest.mark.asyncio
async def test_mouse_up_respects_autocopy_config_disabled() -> None:
"""Test that mouse up does not copy when autocopy_to_clipboard is False."""
from tests.snapshots.base_snapshot_test_app import default_config
config = default_config()
config.autocopy_to_clipboard = False
app = BaseSnapshotTestApp(config=config)
with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy:
async with app.run_test() as pilot:
await pilot.click()
mock_copy.assert_not_called()

16
tests/cli/test_spinner.py Normal file
View file

@ -0,0 +1,16 @@
from __future__ import annotations
import random
from vibe.cli.textual_ui.widgets.spinner import SpinnerType, create_spinner
def test_generate_100_frames_no_crash() -> None:
"""Generate 100 frames per spinner type with seeded random for determinism."""
random.seed(42)
for spinner_type in SpinnerType:
spinner = create_spinner(spinner_type)
for _ in range(100):
frame = spinner.next_frame()
assert isinstance(frame, str)
assert len(frame) > 0

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from textual.selection import Selection
@ -8,8 +8,6 @@ from textual.widget import Widget
from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.textual_ui.app import VibeApp
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
class ClipboardSelectionWidget(Widget):
@ -27,24 +25,15 @@ class ClipboardSelectionWidget(Widget):
@pytest.mark.asyncio
async def test_ui_clipboard_notification_does_not_crash_on_markup_text(
monkeypatch: pytest.MonkeyPatch,
monkeypatch: pytest.MonkeyPatch, vibe_app: VibeApp
) -> None:
agent_loop = AgentLoop(
config=VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
enable_update_checks=False,
)
)
app = VibeApp(agent_loop=agent_loop)
async with app.run_test(notifications=True) as pilot:
await app.mount(ClipboardSelectionWidget("[/]"))
with patch("vibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns:
mock_get_copy_fns.return_value = [MagicMock()]
copy_selection_to_clipboard(app)
async with vibe_app.run_test(notifications=True) as pilot:
await vibe_app.mount(ClipboardSelectionWidget("[/]"))
with patch("vibe.cli.clipboard._copy_osc52"):
copy_selection_to_clipboard(vibe_app)
await pilot.pause(0.1)
notifications = list(app._notifications)
notifications = list(vibe_app._notifications)
assert notifications
notification = notifications[-1]
assert notification.markup is False

View file

@ -0,0 +1,172 @@
from __future__ import annotations
import time
import pytest
from textual.widgets import Button
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from tests.conftest import build_test_agent_loop
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
from vibe.cli.textual_ui.app import ChatScroll, VibeApp
from vibe.cli.textual_ui.widgets.load_more import (
HistoryLoadMoreMessage,
HistoryLoadMoreRequested,
)
from vibe.cli.textual_ui.widgets.messages import UserMessage
from vibe.cli.textual_ui.windowing import (
HISTORY_RESUME_TAIL_MESSAGES,
LOAD_MORE_BATCH_SIZE,
)
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.types import LLMMessage, Role
@pytest.fixture
def vibe_config() -> VibeConfig:
return VibeConfig(
session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False
)
def _pro_plan_gateway() -> FakeWhoAmIGateway:
return FakeWhoAmIGateway(
response=WhoAmIResponse(
is_pro_plan=True,
advertise_pro_plan=False,
prompt_switching_to_pro_plan=False,
)
)
async def _wait_until(pause, predicate, timeout: float = 2.0) -> None:
start = time.monotonic()
while (time.monotonic() - start) < timeout:
if predicate():
return
await pause(0.02)
raise AssertionError("Condition was not met within the timeout")
async def _wait_for_load_more(app: VibeApp, pause) -> None:
await _wait_until(
pause, lambda: len(app.query(HistoryLoadMoreMessage)) == 1, timeout=5.0
)
def _load_more_remaining(app: VibeApp) -> int:
label = app.query_one(HistoryLoadMoreMessage).query_one(Button).label
text = str(label)
_, _, remainder = text.rpartition("(")
return int(remainder.rstrip(")"))
@pytest.mark.asyncio
async def test_ui_session_incremental_loader_shows_tail_and_load_more(
vibe_config: VibeConfig,
) -> None:
agent_loop = build_test_agent_loop(config=vibe_config, enable_streaming=False)
agent_loop.messages.extend([
LLMMessage(role=Role.user, content=f"msg-{idx}") for idx in range(66)
])
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=_pro_plan_gateway())
async with app.run_test() as pilot:
await _wait_until(
pilot.pause,
lambda: len(app.query(UserMessage)) == HISTORY_RESUME_TAIL_MESSAGES,
timeout=5.0,
)
await _wait_for_load_more(app, pilot.pause)
assert len(app.query(UserMessage)) == HISTORY_RESUME_TAIL_MESSAGES
load_more = app.query_one(HistoryLoadMoreMessage)
label = load_more.query_one(Button).label
assert "(" in str(label)
@pytest.mark.asyncio
async def test_ui_session_incremental_loader_load_more_shows_remaining_count(
vibe_config: VibeConfig,
) -> None:
total_messages = 31
agent_loop = build_test_agent_loop(config=vibe_config, enable_streaming=False)
agent_loop.messages.extend([
LLMMessage(role=Role.user, content=f"msg-{idx}")
for idx in range(total_messages)
])
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=_pro_plan_gateway())
async with app.run_test() as pilot:
await _wait_until(
pilot.pause,
lambda: len(app.query(UserMessage)) == HISTORY_RESUME_TAIL_MESSAGES,
timeout=5.0,
)
await _wait_for_load_more(app, pilot.pause)
initial_remaining = total_messages - HISTORY_RESUME_TAIL_MESSAGES
assert _load_more_remaining(app) == initial_remaining
app.post_message(HistoryLoadMoreRequested())
expected_remaining = initial_remaining - LOAD_MORE_BATCH_SIZE
await _wait_until(
pilot.pause,
lambda: _load_more_remaining(app) == expected_remaining,
timeout=5.0,
)
@pytest.mark.asyncio
async def test_ui_session_incremental_loader_load_more_batches_until_done(
vibe_config: VibeConfig,
) -> None:
agent_loop = build_test_agent_loop(config=vibe_config, enable_streaming=False)
agent_loop.messages.extend([
LLMMessage(role=Role.user, content=f"msg-{idx}") for idx in range(31)
])
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=_pro_plan_gateway())
async with app.run_test() as pilot:
await _wait_until(
pilot.pause,
lambda: len(app.query(UserMessage)) == HISTORY_RESUME_TAIL_MESSAGES,
timeout=5.0,
)
await _wait_for_load_more(app, pilot.pause)
total_messages = 31
while len(app.query(HistoryLoadMoreMessage)) == 1:
current_count = len(app.query(UserMessage))
app.post_message(HistoryLoadMoreRequested())
await _wait_until(
pilot.pause,
lambda current_count=current_count: len(app.query(UserMessage))
> current_count,
)
await _wait_until(
pilot.pause, lambda: len(app.query(UserMessage)) == total_messages
)
@pytest.mark.asyncio
async def test_ui_session_incremental_loader_keeps_top_alignment_when_not_scrollable(
vibe_config: VibeConfig,
) -> None:
agent_loop = build_test_agent_loop(config=vibe_config, enable_streaming=False)
agent_loop.messages.extend([
LLMMessage(role=Role.user, content=f"msg-{idx}")
for idx in range(HISTORY_RESUME_TAIL_MESSAGES + 1)
])
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=_pro_plan_gateway())
async with app.run_test(size=(120, 80)) as pilot:
await _wait_for_load_more(app, pilot.pause)
chat = app.query_one("#chat", ChatScroll)
assert chat.max_scroll_y == 0
assert chat.scroll_y == 0

View file

@ -1,29 +1,39 @@
from __future__ import annotations
from pathlib import Path
import time
from unittest.mock import patch
import pytest
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.messages import AssistantMessage, UserMessage
from tests.conftest import (
build_test_agent_loop,
build_test_vibe_app,
build_test_vibe_config,
)
from tests.update_notifier.adapters.fake_update_cache_repository import (
FakeUpdateCacheRepository,
)
from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
from vibe.cli.textual_ui.widgets.messages import (
AssistantMessage,
UserMessage,
WhatsNewMessage,
)
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.cli.update_notifier import UpdateCache
from vibe.core.config import VibeConfig
from vibe.core.types import FunctionCall, LLMMessage, Role, ToolCall
@pytest.fixture
def vibe_config() -> VibeConfig:
return VibeConfig(
session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False
)
@pytest.mark.asyncio
async def test_ui_displays_messages_when_resuming_session(
vibe_config: VibeConfig,
) -> None:
"""Test that messages are properly displayed when resuming a session."""
agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
agent_loop = build_test_agent_loop(config=vibe_config)
# Simulate a previous session with messages
user_msg = LLMMessage(role=Role.user, content="Hello, how are you?")
@ -49,7 +59,7 @@ async def test_ui_displays_messages_when_resuming_session(
agent_loop.messages.extend([user_msg, assistant_msg, tool_result_msg])
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
app = build_test_vibe_app(agent_loop=agent_loop)
async with app.run_test() as pilot:
# Wait for the app to initialize and rebuild history
@ -82,13 +92,13 @@ async def test_ui_does_not_display_messages_when_only_system_messages_exist(
vibe_config: VibeConfig,
) -> None:
"""Test that no messages are displayed when only system messages exist."""
agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
agent_loop = build_test_agent_loop(config=vibe_config)
# Only system messages
system_msg = LLMMessage(role=Role.system, content="System prompt")
agent_loop.messages.append(system_msg)
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
app = build_test_vibe_app(agent_loop=agent_loop)
async with app.run_test() as pilot:
await pilot.pause(0.5)
@ -106,7 +116,7 @@ async def test_ui_displays_multiple_user_assistant_turns(
vibe_config: VibeConfig,
) -> None:
"""Test that multiple conversation turns are properly displayed."""
agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
agent_loop = build_test_agent_loop(config=vibe_config)
# Multiple conversation turns
messages = [
@ -118,7 +128,7 @@ async def test_ui_displays_multiple_user_assistant_turns(
agent_loop.messages.extend(messages)
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
app = build_test_vibe_app(agent_loop=agent_loop)
async with app.run_test() as pilot:
await pilot.pause(0.5)
@ -133,3 +143,55 @@ async def test_ui_displays_multiple_user_assistant_turns(
assert len(assistant_messages) == 2
assert assistant_messages[0]._content == "First answer"
assert assistant_messages[1]._content == "Second answer"
@pytest.mark.asyncio
async def test_ui_rebuilds_history_when_whats_new_is_shown(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
# we have to define an api key to make sure we display the Plan Offer message
monkeypatch.setenv("MISTRAL_API_KEY", "api-key")
config = build_test_vibe_config(enable_update_checks=True)
agent_loop = build_test_agent_loop(config=config)
agent_loop.messages.extend([
LLMMessage(role=Role.user, content="Hello from the previous session."),
LLMMessage(role=Role.assistant, content="Welcome back!"),
])
update_cache = UpdateCache(
latest_version="1.0.0",
stored_at_timestamp=int(time.time()),
seen_whats_new_version=None,
)
update_cache_repository = FakeUpdateCacheRepository(update_cache=update_cache)
plan_offer_gateway = FakeWhoAmIGateway(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=True,
prompt_switching_to_pro_plan=False,
)
)
app = build_test_vibe_app(
agent_loop=agent_loop,
update_notifier=FakeUpdateGateway(update=None),
update_cache_repository=update_cache_repository,
plan_offer_gateway=plan_offer_gateway,
current_version="1.0.0",
config=config,
)
with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
whats_new_file = tmp_path / "whats_new.md"
whats_new_file.write_text("# What's New\n\n- Feature 1")
async with app.run_test() as pilot:
await pilot.pause(0.5)
whats_new_message = app.query_one(WhatsNewMessage)
message = app.query_one(UserMessage)
assistant_message = app.query_one(AssistantMessage)
messages_area = app.query_one("#messages")
children = list(messages_area.children)
assert message._content == "Hello from the previous session."
assert whats_new_message is not None
assert "after-history" in whats_new_message.classes
assert children == [message, assistant_message, whats_new_message]