v2.7.4 (#579)
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai> Co-authored-by: Peter Evers <pevers90@gmail.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Pierre Rossinès <pierre.rossines@protonmail.com> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> 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
90763daf81
commit
e9a9217cc8
113 changed files with 7202 additions and 541 deletions
|
|
@ -124,10 +124,7 @@ def test_copy_selection_to_clipboard_success(
|
|||
assert result == "selected text"
|
||||
mock_copy_to_clipboard.assert_called_once_with("selected text")
|
||||
mock_app.notify.assert_called_once_with(
|
||||
'"selected text" copied to clipboard',
|
||||
severity="information",
|
||||
timeout=2,
|
||||
markup=False,
|
||||
"Selection copied to clipboard", severity="information", timeout=2, markup=False
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -170,32 +167,13 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No
|
|||
"first selection\nsecond selection"
|
||||
)
|
||||
mock_app.notify.assert_called_once_with(
|
||||
'"first selection\u23cesecond selection" copied to clipboard',
|
||||
"Selection copied to clipboard",
|
||||
severity="information",
|
||||
timeout=2,
|
||||
markup=False,
|
||||
)
|
||||
|
||||
|
||||
def test_copy_selection_to_clipboard_preview_shortening(mock_app: MagicMock) -> None:
|
||||
long_text = "a" * 100
|
||||
widget = MockWidget(
|
||||
text_selection=SimpleNamespace(), get_selection_result=(long_text, None)
|
||||
)
|
||||
mock_app.query.return_value = [widget]
|
||||
|
||||
with patch("vibe.cli.clipboard._copy_to_clipboard") as mock_copy_to_clipboard:
|
||||
result = copy_selection_to_clipboard(mock_app)
|
||||
assert result == long_text
|
||||
|
||||
mock_copy_to_clipboard.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]
|
||||
assert "copied to clipboard" in notification_call[0][0]
|
||||
assert len(notification_call[0][0]) < len(long_text) + 30
|
||||
|
||||
|
||||
def test_copy_to_clipboard_stops_after_verified_copy() -> None:
|
||||
"""Stops iterating once _read_clipboard confirms the text landed."""
|
||||
mock_first = MagicMock()
|
||||
|
|
|
|||
|
|
@ -24,46 +24,62 @@ class TestCommandRegistry:
|
|||
assert registry.get_command_name("hello") is None
|
||||
assert registry.get_command_name("") is None
|
||||
|
||||
def test_find_command_returns_command_when_alias_matches(self) -> None:
|
||||
def test_parse_command_returns_command_when_alias_matches(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
cmd = registry.find_command("/help")
|
||||
assert cmd is not None
|
||||
result = registry.parse_command("/help")
|
||||
assert result is not None
|
||||
cmd_name, cmd, cmd_args = result
|
||||
assert cmd_name == "help"
|
||||
assert cmd.handler == "_show_help"
|
||||
assert isinstance(cmd, Command)
|
||||
assert cmd_args == ""
|
||||
|
||||
def test_find_command_returns_none_when_no_match(self) -> None:
|
||||
def test_parse_command_returns_none_when_no_match(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
assert registry.find_command("/nonexistent") is None
|
||||
assert registry.parse_command("/nonexistent") is None
|
||||
|
||||
def test_find_command_uses_get_command_name(self) -> None:
|
||||
"""find_command and get_command_name stay in sync for same input."""
|
||||
def test_parse_command_uses_get_command_name(self) -> None:
|
||||
"""parse_command and get_command_name stay in sync for same input."""
|
||||
registry = CommandRegistry()
|
||||
for alias in ["/help", "/config", "/clear", "/exit"]:
|
||||
cmd_name = registry.get_command_name(alias)
|
||||
cmd = registry.find_command(alias)
|
||||
result = registry.parse_command(alias)
|
||||
if cmd_name is None:
|
||||
assert cmd is None
|
||||
assert result is None
|
||||
else:
|
||||
assert cmd is not None
|
||||
assert cmd_name in registry.commands
|
||||
assert registry.commands[cmd_name] is cmd
|
||||
assert result is not None
|
||||
found_name, found_cmd, _ = result
|
||||
assert found_name == cmd_name
|
||||
assert registry.commands[cmd_name] is found_cmd
|
||||
|
||||
def test_excluded_commands_not_in_registry(self) -> None:
|
||||
registry = CommandRegistry(excluded_commands=["exit"])
|
||||
assert registry.get_command_name("/exit") is None
|
||||
assert registry.find_command("/exit") is None
|
||||
assert registry.parse_command("/exit") is None
|
||||
assert registry.get_command_name("/help") == "help"
|
||||
|
||||
def test_resume_command_registration(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
assert registry.get_command_name("/resume") == "resume"
|
||||
assert registry.get_command_name("/continue") == "resume"
|
||||
cmd = registry.find_command("/resume")
|
||||
assert cmd is not None
|
||||
result = registry.parse_command("/resume")
|
||||
assert result is not None
|
||||
_, cmd, _ = result
|
||||
assert cmd.handler == "_show_session_picker"
|
||||
|
||||
def test_parse_command_keeps_args_for_no_arg_commands(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
result = registry.parse_command("/help extra")
|
||||
assert result == ("help", registry.commands["help"], "extra")
|
||||
|
||||
def test_parse_command_keeps_args_for_argument_commands(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
result = registry.parse_command("/mcp filesystem")
|
||||
assert result == ("mcp", registry.commands["mcp"], "filesystem")
|
||||
|
||||
def test_data_retention_command_registration(self) -> None:
|
||||
registry = CommandRegistry()
|
||||
cmd = registry.find_command("/data-retention")
|
||||
assert cmd is not None
|
||||
result = registry.parse_command("/data-retention")
|
||||
assert result is not None
|
||||
_, cmd, _ = result
|
||||
assert cmd.handler == "_show_data_retention"
|
||||
|
|
|
|||
156
tests/cli/test_mcp_app.py
Normal file
156
tests/cli/test_mcp_app.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from vibe.cli.textual_ui.widgets.mcp_app import MCPApp, collect_mcp_tool_index
|
||||
from vibe.core.config import MCPStdio
|
||||
from vibe.core.tools.base import InvokeContext
|
||||
from vibe.core.tools.mcp.tools import MCPTool, MCPToolResult, _OpenArgs
|
||||
from vibe.core.types import ToolStreamEvent
|
||||
|
||||
|
||||
def _make_tool_cls(
|
||||
*,
|
||||
is_mcp: bool,
|
||||
description: str = "",
|
||||
server_name: str | None = None,
|
||||
remote_name: str = "tool",
|
||||
) -> type:
|
||||
if not is_mcp:
|
||||
return type("FakeTool", (), {"description": description})
|
||||
|
||||
async def _run(
|
||||
self: Any, args: _OpenArgs, ctx: InvokeContext | None = None
|
||||
) -> AsyncGenerator[ToolStreamEvent | MCPToolResult, None]:
|
||||
yield MCPToolResult(ok=True, server="", tool="", text=None)
|
||||
|
||||
return type(
|
||||
"FakeMCPTool",
|
||||
(MCPTool,),
|
||||
{
|
||||
"description": description,
|
||||
"_server_name": server_name,
|
||||
"_remote_name": remote_name,
|
||||
"run": _run,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _make_tool_manager(
|
||||
all_tools: dict[str, type], available_tools: dict[str, type] | None = None
|
||||
) -> MagicMock:
|
||||
mgr = MagicMock()
|
||||
mgr.registered_tools = all_tools
|
||||
mgr.available_tools = available_tools if available_tools is not None else all_tools
|
||||
return mgr
|
||||
|
||||
|
||||
class TestCollectMcpToolIndex:
|
||||
def test_non_mcp_tools_are_excluded(self) -> None:
|
||||
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
|
||||
all_tools = {
|
||||
"srv_tool": _make_tool_cls(is_mcp=True, server_name="srv"),
|
||||
"bash": _make_tool_cls(is_mcp=False),
|
||||
}
|
||||
mgr = _make_tool_manager(all_tools)
|
||||
|
||||
index = collect_mcp_tool_index(servers, mgr)
|
||||
|
||||
assert "bash" not in str(index.server_tools)
|
||||
assert len(index.server_tools["srv"]) == 1
|
||||
|
||||
def test_counts_match_available_vs_all(self) -> None:
|
||||
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
|
||||
tool_a = _make_tool_cls(is_mcp=True, server_name="srv", remote_name="tool_a")
|
||||
tool_b = _make_tool_cls(is_mcp=True, server_name="srv", remote_name="tool_b")
|
||||
all_tools = {"srv_tool_a": tool_a, "srv_tool_b": tool_b}
|
||||
available = {"srv_tool_a": tool_a}
|
||||
mgr = _make_tool_manager(all_tools, available)
|
||||
|
||||
index = collect_mcp_tool_index(servers, mgr)
|
||||
|
||||
assert len(index.server_tools["srv"]) == 2
|
||||
enabled = sum(
|
||||
1 for t, _ in index.server_tools["srv"] if t in index.enabled_tools
|
||||
)
|
||||
assert enabled == 1
|
||||
|
||||
def test_tool_with_no_matching_server_is_skipped(self) -> None:
|
||||
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
|
||||
all_tools = {"other_tool": _make_tool_cls(is_mcp=True, server_name="other")}
|
||||
mgr = _make_tool_manager(all_tools)
|
||||
|
||||
index = collect_mcp_tool_index(servers, mgr)
|
||||
|
||||
assert index.server_tools == {}
|
||||
|
||||
def test_empty_servers_returns_empty(self) -> None:
|
||||
mgr = _make_tool_manager({
|
||||
"srv_tool": _make_tool_cls(is_mcp=True, server_name="srv")
|
||||
})
|
||||
index = collect_mcp_tool_index([], mgr)
|
||||
assert index.server_tools == {}
|
||||
|
||||
|
||||
class TestMCPAppInit:
|
||||
def test_viewing_server_none_when_no_initial_server(self) -> None:
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[], tool_manager=mgr)
|
||||
assert app._viewing_server is None
|
||||
|
||||
def test_initial_server_stripped_and_stored(self) -> None:
|
||||
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=servers, tool_manager=mgr, initial_server=" srv ")
|
||||
assert app._viewing_server == "srv"
|
||||
|
||||
def test_widget_id_is_mcp_app(self) -> None:
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[], tool_manager=mgr)
|
||||
assert app.id == "mcp-app"
|
||||
|
||||
def test_refresh_view_unknown_server_falls_back_to_overview(self) -> None:
|
||||
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=servers, tool_manager=mgr)
|
||||
app.query_one = MagicMock()
|
||||
app._refresh_view("nonexistent")
|
||||
assert app._viewing_server is None
|
||||
|
||||
def test_refresh_view_known_server_sets_viewing_server(self) -> None:
|
||||
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=servers, tool_manager=mgr)
|
||||
app.query_one = MagicMock()
|
||||
app._refresh_view("srv")
|
||||
assert app._viewing_server == "srv"
|
||||
|
||||
def test_refresh_view_none_clears_viewing_server(self) -> None:
|
||||
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=servers, tool_manager=mgr)
|
||||
app._viewing_server = "srv"
|
||||
app.query_one = MagicMock()
|
||||
app._refresh_view(None)
|
||||
assert app._viewing_server is None
|
||||
|
||||
def test_action_back_calls_refresh_view_none(self) -> None:
|
||||
servers = [MCPStdio(name="srv", transport="stdio", command="cmd")]
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=servers, tool_manager=mgr)
|
||||
app._viewing_server = "srv"
|
||||
render_calls: list[str | None] = []
|
||||
app._refresh_view = lambda server_name: render_calls.append(server_name)
|
||||
app.action_back()
|
||||
assert render_calls == [None]
|
||||
|
||||
def test_action_back_noop_when_in_overview(self) -> None:
|
||||
mgr = _make_tool_manager({})
|
||||
app = MCPApp(mcp_servers=[], tool_manager=mgr)
|
||||
app._viewing_server = None
|
||||
render_calls: list[str | None] = []
|
||||
app._refresh_view = lambda server_name: render_calls.append(server_name)
|
||||
app.action_back()
|
||||
assert render_calls == []
|
||||
|
|
@ -37,4 +37,4 @@ async def test_ui_clipboard_notification_does_not_crash_on_markup_text(
|
|||
assert notifications
|
||||
notification = notifications[-1]
|
||||
assert notification.markup is False
|
||||
assert "copied to clipboard" in notification.message
|
||||
assert "Selection copied to clipboard" in notification.message
|
||||
|
|
|
|||
44
tests/cli/textual_ui/test_debug_console_resize.py
Normal file
44
tests/cli/textual_ui/test_debug_console_resize.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""Test that _LogView.render_line handles width mismatches during resize."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.geometry import Size
|
||||
from textual.strip import Strip
|
||||
|
||||
from vibe.cli.textual_ui.widgets.debug_console import _LogView
|
||||
|
||||
|
||||
class _LogViewTestApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
self._log_view = _LogView(
|
||||
load_page=lambda: None, has_more=lambda: False, id="test-log-view"
|
||||
)
|
||||
yield self._log_view
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_line_no_keyerror_on_width_mismatch():
|
||||
"""render_line must not raise KeyError when wrap width != cached width."""
|
||||
app = _LogViewTestApp()
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
log_view = app._log_view
|
||||
|
||||
long_line = "A" * 200
|
||||
log_view.write_line(long_line)
|
||||
await pilot.pause()
|
||||
|
||||
assert log_view._total_visual == 3
|
||||
assert log_view._cached_width == 80
|
||||
|
||||
# At width 120, wrapping produces 2 lines, but _wrap_prefix says 3.
|
||||
new_size = Size(120, 24)
|
||||
log_view._render_line_cache.clear()
|
||||
with patch.object(
|
||||
type(log_view), "size", new_callable=PropertyMock, return_value=new_size
|
||||
):
|
||||
result = log_view.render_line(2)
|
||||
assert isinstance(result, Strip)
|
||||
132
tests/cli/textual_ui/test_manual_command_output_cap.py
Normal file
132
tests/cli/textual_ui/test_manual_command_output_cap.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_agent_loop, build_test_vibe_app
|
||||
from tests.mock.utils import mock_llm_chunk
|
||||
from tests.stubs.fake_backend import FakeBackend
|
||||
from vibe.cli.textual_ui.app import VibeApp
|
||||
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
|
||||
from vibe.cli.textual_ui.widgets.messages import BashOutputMessage
|
||||
from vibe.core.types import Role
|
||||
|
||||
|
||||
class TestCapOutput:
|
||||
"""Unit tests for VibeApp._cap_output."""
|
||||
|
||||
def test_short_text_unchanged(self) -> None:
|
||||
assert VibeApp._cap_output("hello", 100) == "hello"
|
||||
|
||||
def test_exact_limit_unchanged(self) -> None:
|
||||
text = "a" * 50
|
||||
assert VibeApp._cap_output(text, 50) == text
|
||||
|
||||
def test_over_limit_truncated(self) -> None:
|
||||
text = "a" * 100
|
||||
result = VibeApp._cap_output(text, 50)
|
||||
assert result == "a" * 50 + "\n... [truncated]"
|
||||
|
||||
def test_empty_string_unchanged(self) -> None:
|
||||
assert VibeApp._cap_output("", 10) == ""
|
||||
|
||||
|
||||
class TestFormatManualCommandContext:
|
||||
"""Unit tests for VibeApp._format_manual_command_context with output capping."""
|
||||
|
||||
@pytest.fixture
|
||||
def app(self) -> VibeApp:
|
||||
return build_test_vibe_app()
|
||||
|
||||
def test_stdout_capped_in_context(self, app: VibeApp) -> None:
|
||||
limit = app._get_bash_max_output_bytes()
|
||||
big_stdout = "x" * (limit + 5000)
|
||||
|
||||
result = app._format_manual_command_context(
|
||||
command="cat big.log", cwd="/tmp", stdout=big_stdout, exit_code=0
|
||||
)
|
||||
|
||||
assert "[truncated]" in result
|
||||
# The raw oversized content must not appear in the formatted output
|
||||
assert big_stdout not in result
|
||||
|
||||
def test_stderr_capped_in_context(self, app: VibeApp) -> None:
|
||||
limit = app._get_bash_max_output_bytes()
|
||||
big_stderr = "E" * (limit + 1000)
|
||||
|
||||
result = app._format_manual_command_context(
|
||||
command="make build", cwd="/tmp", stderr=big_stderr, exit_code=1
|
||||
)
|
||||
|
||||
assert "[truncated]" in result
|
||||
assert big_stderr not in result
|
||||
|
||||
def test_small_output_not_truncated(self, app: VibeApp) -> None:
|
||||
result = app._format_manual_command_context(
|
||||
command="echo hi", cwd="/tmp", stdout="hi\n", exit_code=0
|
||||
)
|
||||
|
||||
assert "[truncated]" not in result
|
||||
assert "hi" in result
|
||||
|
||||
def test_both_stdout_and_stderr_capped_independently(self, app: VibeApp) -> None:
|
||||
limit = app._get_bash_max_output_bytes()
|
||||
big_stdout = "O" * (limit + 100)
|
||||
big_stderr = "E" * (limit + 100)
|
||||
|
||||
result = app._format_manual_command_context(
|
||||
command="cmd", cwd="/tmp", stdout=big_stdout, stderr=big_stderr, exit_code=1
|
||||
)
|
||||
|
||||
# Both should be truncated
|
||||
assert result.count("[truncated]") == 2
|
||||
|
||||
|
||||
class TestGetBashMaxOutputBytes:
|
||||
"""Test that _get_bash_max_output_bytes reads from the tool config."""
|
||||
|
||||
def test_returns_default_value(self) -> None:
|
||||
from vibe.core.tools.builtins.bash import BashToolConfig
|
||||
|
||||
app = build_test_vibe_app()
|
||||
result = app._get_bash_max_output_bytes()
|
||||
assert result == BashToolConfig().max_output_bytes
|
||||
|
||||
def test_returns_positive_int(self) -> None:
|
||||
app = build_test_vibe_app()
|
||||
assert app._get_bash_max_output_bytes() > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_large_bang_command_output_is_capped_in_history() -> None:
|
||||
"""Integration test: !command output injected into history respects the cap."""
|
||||
backend = FakeBackend(mock_llm_chunk(content="ok"))
|
||||
app = build_test_vibe_app(agent_loop=build_test_agent_loop(backend=backend))
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
limit = app._get_bash_max_output_bytes()
|
||||
# Generate output larger than the cap.
|
||||
# seq lines average ~4 chars for small numbers but grow; use
|
||||
# a generous count to guarantee we exceed the byte limit.
|
||||
repeat = (limit // 2) + 100
|
||||
cmd = f"!seq 1 {repeat}"
|
||||
|
||||
chat_input = app.query_one(ChatInputContainer)
|
||||
chat_input.value = cmd
|
||||
await pilot.press("enter")
|
||||
|
||||
deadline = time.monotonic() + 2.0
|
||||
while time.monotonic() < deadline:
|
||||
if next(iter(app.query(BashOutputMessage)), None):
|
||||
break
|
||||
await pilot.pause(0.05)
|
||||
|
||||
injected = app.agent_loop.messages[-1]
|
||||
assert injected.role == Role.user
|
||||
assert injected.injected is True
|
||||
assert injected.content is not None
|
||||
assert "[truncated]" in injected.content
|
||||
# The injected content should be bounded; allow generous margin for
|
||||
# formatting overhead but ensure it's not the full raw output.
|
||||
assert len(injected.content) < limit * 3
|
||||
Loading…
Add table
Add a link
Reference in a new issue