v2.11.0 (#717)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
f71bfd3b8c
commit
adb1ca74ce
202 changed files with 5828 additions and 1968 deletions
|
|
@ -5,12 +5,12 @@ import time
|
|||
import tomllib
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from vibe.cli.textual_ui.widgets.feedback_bar_manager import (
|
||||
from vibe.cli.textual_ui.widgets.feedback_bar_manager import FeedbackBarManager
|
||||
from vibe.core.feedback import (
|
||||
_CACHE_SECTION,
|
||||
_LAST_SHOWN_KEY,
|
||||
FEEDBACK_COOLDOWN_SECONDS,
|
||||
MIN_USER_MESSAGES_FOR_FEEDBACK,
|
||||
FeedbackBarManager,
|
||||
)
|
||||
from vibe.core.types import LLMMessage, Role
|
||||
|
||||
|
|
@ -19,15 +19,12 @@ def _patch_cache_file(tmp_path: Path):
|
|||
from vibe.core.paths._vibe_home import GlobalPath
|
||||
|
||||
return patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar_manager.CACHE_FILE",
|
||||
GlobalPath(lambda: tmp_path / "cache.toml"),
|
||||
"vibe.core.feedback.CACHE_FILE", GlobalPath(lambda: tmp_path / "cache.toml")
|
||||
)
|
||||
|
||||
|
||||
def _patch_probability(value: float):
|
||||
return patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar_manager.FEEDBACK_PROBABILITY", value
|
||||
)
|
||||
return patch("vibe.core.feedback.FEEDBACK_PROBABILITY", value)
|
||||
|
||||
|
||||
def _make_agent_loop(
|
||||
|
|
@ -50,10 +47,7 @@ class TestShouldShow:
|
|||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
|
||||
return_value=0.0,
|
||||
),
|
||||
patch("vibe.core.feedback.random.random", return_value=0.0),
|
||||
):
|
||||
assert manager.should_show(_make_agent_loop()) is True
|
||||
|
||||
|
|
@ -62,10 +56,7 @@ class TestShouldShow:
|
|||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
|
||||
return_value=1.0,
|
||||
),
|
||||
patch("vibe.core.feedback.random.random", return_value=1.0),
|
||||
):
|
||||
assert manager.should_show(_make_agent_loop()) is False
|
||||
|
||||
|
|
@ -77,10 +68,7 @@ class TestShouldShow:
|
|||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
|
||||
return_value=0.0,
|
||||
),
|
||||
patch("vibe.core.feedback.random.random", return_value=0.0),
|
||||
):
|
||||
assert manager.should_show(_make_agent_loop()) is False
|
||||
|
||||
|
|
@ -92,10 +80,7 @@ class TestShouldShow:
|
|||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
|
||||
return_value=0.0,
|
||||
),
|
||||
patch("vibe.core.feedback.random.random", return_value=0.0),
|
||||
):
|
||||
assert manager.should_show(_make_agent_loop()) is True
|
||||
|
||||
|
|
@ -111,10 +96,7 @@ class TestShouldShow:
|
|||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
|
||||
return_value=0.0,
|
||||
),
|
||||
patch("vibe.core.feedback.random.random", return_value=0.0),
|
||||
):
|
||||
assert manager.should_show(_make_agent_loop(user_message_count=1)) is False
|
||||
|
||||
|
|
@ -129,10 +111,7 @@ class TestShouldShow:
|
|||
with (
|
||||
_patch_cache_file(tmp_path),
|
||||
_patch_probability(0.2),
|
||||
patch(
|
||||
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
|
||||
return_value=0.0,
|
||||
),
|
||||
patch("vibe.core.feedback.random.random", return_value=0.0),
|
||||
):
|
||||
# Only 1 non-injected user message, below MIN_USER_MESSAGES_FOR_FEEDBACK
|
||||
assert manager.should_show(loop) is False
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from typing import Any
|
|||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
from tests.stubs.fake_connector_registry import FakeConnectorRegistry
|
||||
from vibe.cli.textual_ui.widgets.mcp_app import (
|
||||
|
|
@ -17,7 +18,10 @@ from vibe.cli.textual_ui.widgets.mcp_app import (
|
|||
)
|
||||
from vibe.core.config import MCPStdio
|
||||
from vibe.core.tools.base import InvokeContext
|
||||
from vibe.core.tools.connectors.connector_registry import RemoteTool
|
||||
from vibe.core.tools.connectors.connector_registry import (
|
||||
ConnectorAuthAction,
|
||||
RemoteTool,
|
||||
)
|
||||
from vibe.core.tools.mcp.tools import MCPTool, MCPToolResult, _OpenArgs
|
||||
from vibe.core.types import ToolStreamEvent
|
||||
|
||||
|
|
@ -271,7 +275,8 @@ class TestHelpBarChanges:
|
|||
connectors={
|
||||
"gmail": [RemoteTool(name="search", description="Search")],
|
||||
"slack": [],
|
||||
}
|
||||
},
|
||||
auth_actions={"slack": ConnectorAuthAction.OAUTH},
|
||||
)
|
||||
mgr = _make_tool_manager({})
|
||||
return MCPApp(
|
||||
|
|
@ -282,33 +287,35 @@ class TestHelpBarChanges:
|
|||
|
||||
def test_help_shows_show_tools_for_server(self) -> None:
|
||||
app = self._make_app_with_connectors()
|
||||
option = MagicMock()
|
||||
option.id = "server:srv"
|
||||
option = Option("", id="server:srv")
|
||||
assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS
|
||||
|
||||
def test_help_shows_show_tools_for_connected_connector(self) -> None:
|
||||
app = self._make_app_with_connectors()
|
||||
option = MagicMock()
|
||||
option.id = "connector:gmail"
|
||||
option = Option("", id="connector:gmail")
|
||||
assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS
|
||||
|
||||
def test_help_shows_authenticate_for_disconnected_connector(self) -> None:
|
||||
app = self._make_app_with_connectors()
|
||||
option = MagicMock()
|
||||
option.id = "connector:slack"
|
||||
option = Option("", id="connector:slack")
|
||||
assert app._list_help_for_option(option) == _LIST_VIEW_HELP_AUTH
|
||||
|
||||
def test_help_shows_tools_for_degraded_connector(self) -> None:
|
||||
registry = FakeConnectorRegistry(connectors={"slack": []})
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[], tool_manager=mgr, connector_registry=registry)
|
||||
option = Option("", id="connector:slack")
|
||||
assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS
|
||||
|
||||
def test_help_defaults_to_tools_for_unknown_option(self) -> None:
|
||||
app = self._make_app_with_connectors()
|
||||
option = MagicMock()
|
||||
option.id = ""
|
||||
option = Option("")
|
||||
assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS
|
||||
|
||||
def test_help_shows_tools_without_registry(self) -> None:
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[], tool_manager=mgr)
|
||||
option = MagicMock()
|
||||
option.id = "connector:slack"
|
||||
option = Option("", id="connector:slack")
|
||||
assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS
|
||||
|
||||
|
||||
|
|
@ -316,7 +323,9 @@ class TestConnectorAuthRequested:
|
|||
"""Clicking a disconnected connector posts ConnectorAuthRequested."""
|
||||
|
||||
def test_disconnected_connector_posts_auth_requested(self) -> None:
|
||||
registry = FakeConnectorRegistry(connectors={"slack": []})
|
||||
registry = FakeConnectorRegistry(
|
||||
connectors={"slack": []}, auth_actions={"slack": ConnectorAuthAction.OAUTH}
|
||||
)
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[], tool_manager=mgr, connector_registry=registry)
|
||||
app.query_one = MagicMock()
|
||||
|
|
@ -334,6 +343,24 @@ class TestConnectorAuthRequested:
|
|||
assert msg.connector_registry is registry
|
||||
assert msg.tool_manager is mgr
|
||||
|
||||
def test_degraded_connector_shows_refresh_message(self) -> None:
|
||||
registry = FakeConnectorRegistry(connectors={"slack": []})
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[], tool_manager=mgr, connector_registry=registry)
|
||||
app.query_one = MagicMock()
|
||||
app.post_message = MagicMock()
|
||||
|
||||
option_list = MagicMock()
|
||||
app._show_detail_view(
|
||||
"slack", option_list, app._index, kind=MCPSourceKind.CONNECTOR
|
||||
)
|
||||
|
||||
app.post_message.assert_not_called()
|
||||
option_list.add_option.assert_called_once()
|
||||
assert "press R to refresh" in str(
|
||||
option_list.add_option.call_args.args[0].prompt
|
||||
)
|
||||
|
||||
def test_connected_connector_with_no_indexed_tools_shows_message(self) -> None:
|
||||
registry = MagicMock()
|
||||
registry.get_connector_names.return_value = ["slack"]
|
||||
|
|
|
|||
93
tests/cli/test_ui_theme_picker.py
Normal file
93
tests/cli/test_ui_theme_picker.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
||||
from vibe.cli.textual_ui.app import BottomApp
|
||||
from vibe.cli.textual_ui.widgets.theme_picker import ThemePickerApp
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_theme_opens_theme_picker() -> None:
|
||||
app = build_test_vibe_app(config=build_test_vibe_config())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_theme()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert app._current_bottom_app == BottomApp.ThemePicker
|
||||
assert len(app.query(ThemePickerApp)) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_theme_picker_lists_themes_and_marks_current() -> None:
|
||||
config = build_test_vibe_config()
|
||||
config.theme = "dracula"
|
||||
app = build_test_vibe_app(config=config)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_theme()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
picker = app.query_one(ThemePickerApp)
|
||||
assert "dracula" in picker._theme_names
|
||||
assert "ansi-dark" in picker._theme_names
|
||||
assert picker._current_theme == "dracula"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_theme_picker_escape_restores_original_theme() -> None:
|
||||
config = build_test_vibe_config()
|
||||
config.theme = "ansi-dark"
|
||||
app = build_test_vibe_app(config=config)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_theme()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
# Move highlight to a different theme to trigger preview.
|
||||
await pilot.press("down")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save:
|
||||
await pilot.press("escape")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
mock_save.assert_not_called()
|
||||
|
||||
assert app._current_bottom_app == BottomApp.Input
|
||||
assert len(app.query(ThemePickerApp)) == 0
|
||||
assert app.config.theme == "ansi-dark"
|
||||
assert app.theme == "ansi-dark"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_theme_picker_select_persists_and_applies() -> None:
|
||||
config = build_test_vibe_config()
|
||||
config.theme = "ansi-dark"
|
||||
app = build_test_vibe_app(config=config)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
await app._show_theme()
|
||||
await pilot.pause(0.2)
|
||||
|
||||
picker = app.query_one(ThemePickerApp)
|
||||
names = picker._theme_names
|
||||
current_index = names.index("ansi-dark")
|
||||
target_index = (current_index + 1) % len(names)
|
||||
target = names[target_index]
|
||||
|
||||
await pilot.press("down")
|
||||
|
||||
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save:
|
||||
await pilot.press("enter")
|
||||
await pilot.pause(0.2)
|
||||
|
||||
mock_save.assert_called_once_with({"theme": target})
|
||||
|
||||
assert app._current_bottom_app == BottomApp.Input
|
||||
assert len(app.query(ThemePickerApp)) == 0
|
||||
assert app.config.theme == target
|
||||
assert app.theme == target
|
||||
144
tests/cli/textual_ui/test_message_queue.py
Normal file
144
tests/cli/textual_ui/test_message_queue.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.cli.textual_ui.message_queue import MessageQueue, QueuedItemKind
|
||||
|
||||
|
||||
def test_empty_queue_is_falsy() -> None:
|
||||
queue = MessageQueue()
|
||||
assert not queue
|
||||
assert len(queue) == 0
|
||||
assert not queue.paused
|
||||
|
||||
|
||||
def test_append_prompt_increases_length() -> None:
|
||||
queue = MessageQueue()
|
||||
queue.append_prompt("hello")
|
||||
assert len(queue) == 1
|
||||
assert queue.items[0].kind == QueuedItemKind.PROMPT
|
||||
assert queue.items[0].content == "hello"
|
||||
|
||||
|
||||
def test_append_bash_marks_kind() -> None:
|
||||
queue = MessageQueue()
|
||||
queue.append_bash("ls")
|
||||
assert queue.items[0].kind == QueuedItemKind.BASH
|
||||
|
||||
|
||||
def test_pop_last_returns_newest() -> None:
|
||||
queue = MessageQueue()
|
||||
queue.append_prompt("a")
|
||||
queue.append_prompt("b")
|
||||
queue.append_prompt("c")
|
||||
|
||||
popped = queue.pop_last()
|
||||
assert popped is not None
|
||||
assert popped.content == "c"
|
||||
assert [item.content for item in queue.items] == ["a", "b"]
|
||||
|
||||
|
||||
def test_pop_first_returns_oldest() -> None:
|
||||
queue = MessageQueue()
|
||||
queue.append_prompt("a")
|
||||
queue.append_bash("ls")
|
||||
queue.append_prompt("c")
|
||||
|
||||
first = queue.pop_first()
|
||||
assert first is not None
|
||||
assert first.content == "a"
|
||||
assert first.kind == QueuedItemKind.PROMPT
|
||||
|
||||
second = queue.pop_first()
|
||||
assert second is not None
|
||||
assert second.content == "ls"
|
||||
assert second.kind == QueuedItemKind.BASH
|
||||
|
||||
|
||||
def test_pop_from_empty_returns_none() -> None:
|
||||
queue = MessageQueue()
|
||||
assert queue.pop_first() is None
|
||||
assert queue.pop_last() is None
|
||||
|
||||
|
||||
def test_pause_and_resume() -> None:
|
||||
queue = MessageQueue()
|
||||
queue.append_prompt("a")
|
||||
|
||||
queue.pause()
|
||||
assert queue.paused
|
||||
|
||||
queue.resume()
|
||||
assert not queue.paused
|
||||
|
||||
|
||||
def test_pause_is_idempotent() -> None:
|
||||
queue = MessageQueue()
|
||||
calls = []
|
||||
queue.set_change_listener(lambda: calls.append(None))
|
||||
queue.pause()
|
||||
queue.pause()
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_clear_resets_state() -> None:
|
||||
queue = MessageQueue()
|
||||
queue.append_prompt("a")
|
||||
queue.pause()
|
||||
queue.clear()
|
||||
assert not queue
|
||||
assert not queue.paused
|
||||
|
||||
|
||||
def test_change_listener_fires_on_mutations() -> None:
|
||||
queue = MessageQueue()
|
||||
calls: list[None] = []
|
||||
queue.set_change_listener(lambda: calls.append(None))
|
||||
|
||||
queue.append_prompt("a")
|
||||
assert len(calls) == 1
|
||||
|
||||
queue.append_bash("ls")
|
||||
assert len(calls) == 2
|
||||
|
||||
queue.pop_last()
|
||||
assert len(calls) == 3
|
||||
|
||||
queue.pause()
|
||||
assert len(calls) == 4
|
||||
|
||||
queue.resume()
|
||||
assert len(calls) == 5
|
||||
|
||||
|
||||
def test_change_listener_can_be_cleared() -> None:
|
||||
queue = MessageQueue()
|
||||
calls: list[None] = []
|
||||
queue.set_change_listener(lambda: calls.append(None))
|
||||
queue.set_change_listener(None)
|
||||
queue.append_prompt("a")
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_items_returns_copy() -> None:
|
||||
queue = MessageQueue()
|
||||
queue.append_prompt("a")
|
||||
snapshot = queue.items
|
||||
queue.append_prompt("b")
|
||||
assert len(snapshot) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind,content",
|
||||
[(QueuedItemKind.PROMPT, "hello world"), (QueuedItemKind.BASH, "echo 'hi'")],
|
||||
)
|
||||
def test_item_kinds_round_trip(kind: QueuedItemKind, content: str) -> None:
|
||||
queue = MessageQueue()
|
||||
if kind == QueuedItemKind.PROMPT:
|
||||
queue.append_prompt(content)
|
||||
else:
|
||||
queue.append_bash(content)
|
||||
item = queue.pop_first()
|
||||
assert item is not None
|
||||
assert item.kind == kind
|
||||
assert item.content == content
|
||||
Loading…
Add table
Add a link
Reference in a new issue