Co-authored-by: Albert Jiang <aj@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Laure Hugo <201583486+laure0303@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Mert Unsal <mert.unsal@mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
maiengineering 2026-07-01 19:03:09 +02:00 committed by GitHub
parent 4e495f658d
commit ac8f1a09fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 1979 additions and 572 deletions

View file

@ -17,7 +17,8 @@ def test_help_shows_auto_approve_flag(
output = capsys.readouterr().out
assert "--auto-approve" in output
assert "--yolo" in output
assert "Shortcut for --agent auto-approve" in output
assert "Approves all tool calls without prompting" in output
assert "selected agent" in output
def test_help_shows_check_upgrade_flag(
@ -47,13 +48,12 @@ def test_yolo_alias_selects_auto_approve(monkeypatch: pytest.MonkeyPatch) -> Non
@pytest.mark.parametrize("flag", ["--auto-approve", "--yolo"])
def test_auto_approve_aliases_conflict_with_agent(
flag: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
def test_auto_approve_aliases_can_be_combined_with_agent(
flag: str, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("sys.argv", ["vibe", "--agent", "plan", flag])
monkeypatch.setattr("sys.argv", ["vibe", "--agent", "lean", flag])
with pytest.raises(SystemExit) as exc_info:
parse_arguments()
args = parse_arguments()
assert exc_info.value.code == 2
assert "not allowed with argument --agent" in capsys.readouterr().err
assert args.agent == "lean"
assert args.auto_approve is True

View file

@ -55,8 +55,15 @@ def test_programmatic_mode_keeps_explicit_agent_arg() -> None:
assert get_initial_agent_name(args, config) == "accept-edits"
def test_auto_approve_flag_selects_auto_approve_agent() -> None:
def test_auto_approve_flag_keeps_config_default_agent() -> None:
config = VibeConfig.model_construct(default_agent=BuiltinAgentName.PLAN)
args = _make_args(agent=None, prompt="hello", auto_approve=True)
assert get_initial_agent_name(args, config) == BuiltinAgentName.AUTO_APPROVE
assert get_initial_agent_name(args, config) == BuiltinAgentName.PLAN
def test_auto_approve_flag_keeps_explicit_agent_arg() -> None:
config = VibeConfig.model_construct(default_agent=BuiltinAgentName.PLAN)
args = _make_args(agent="lean", prompt="hello", auto_approve=True)
assert get_initial_agent_name(args, config) == BuiltinAgentName.LEAN

View file

@ -22,6 +22,7 @@ def _make_args(**overrides: object) -> argparse.Namespace:
"enabled_tools": None,
"output": "text",
"agent": "default",
"auto_approve": False,
"check_upgrade": False,
"setup": False,
"workdir": None,
@ -301,6 +302,35 @@ def test_run_cli_passes_max_tokens_to_run_programmatic(
assert call["max_session_tokens"] == 123
def test_run_cli_auto_approve_sets_config_without_changing_agent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
args = _make_args(agent="lean", auto_approve=True)
call: dict[str, object] = {}
config = build_test_vibe_config(default_agent="plan")
monkeypatch.setattr(cli_mod, "bootstrap_config_files", lambda: None)
monkeypatch.setattr(cli_mod, "load_config_or_exit", lambda interactive: config)
monkeypatch.setattr(cli_mod, "load_hooks_from_fs", lambda _config: None)
monkeypatch.setattr(cli_mod, "setup_tracing", lambda _config: None)
monkeypatch.setattr(cli_mod, "load_session", lambda _args, _config: None)
monkeypatch.setattr(cli_mod, "get_prompt_from_stdin", lambda: None)
monkeypatch.setattr(cli_mod, "warn_if_workdir_trust_is_unset", lambda: None)
def fake_run_programmatic(**kwargs: object) -> str:
call.update(kwargs)
return "done"
monkeypatch.setattr(cli_mod, "run_programmatic", fake_run_programmatic)
with pytest.raises(SystemExit) as exc_info:
cli_mod.run_cli(args)
assert exc_info.value.code == 0
assert call["agent_name"] == "lean"
assert config.bypass_tool_permissions is True
def test_run_cli_runs_update_prompt_before_trust_resolver(
monkeypatch: pytest.MonkeyPatch,
) -> None:

View file

@ -37,6 +37,7 @@ def test_detects_cursor_from_vscode_environment() -> None:
@pytest.mark.parametrize(
("term_program", "terminal"),
[
("Apple_Terminal", Terminal.APPLE_TERMINAL),
("iterm.app", Terminal.ITERM2),
("wezterm", Terminal.WEZTERM),
("ghostty", Terminal.GHOSTTY),
@ -58,6 +59,7 @@ def test_detects_term_program_mapping(term_program: str, terminal: Terminal) ->
("ALACRITTY_SOCKET", Terminal.ALACRITTY),
("ALACRITTY_LOG", Terminal.ALACRITTY),
("WT_SESSION", Terminal.WINDOWS_TERMINAL),
("WT_PROFILE_ID", Terminal.WINDOWS_TERMINAL),
],
)
def test_detects_environment_marker_fallback(env_var: str, terminal: Terminal) -> None:

View file

@ -9,12 +9,14 @@ import pytest
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from tests.conftest import build_test_vibe_app, build_test_vibe_config
from tests.constants import OPENAI_BASE_URL
from vibe import __version__
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
from vibe.cli.textual_ui.widgets.messages import ErrorMessage
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
from vibe.core.types import Backend
from vibe.core.utils import get_platform_id, get_platform_version
def _chat_plan_gateway(*, prompt_switching_to_pro_plan: bool) -> FakeWhoAmIGateway:
@ -40,6 +42,13 @@ async def _wait_until(pause, predicate, timeout: float = 2.0) -> None:
raise AssertionError("Condition was not met within the timeout")
def _expected_system_metadata() -> dict[str, Any]:
metadata: dict[str, Any] = {"os": get_platform_id(), "version": __version__}
if os_version := get_platform_version():
metadata["os_version"] = os_version
return metadata
def _teleport_failed_events(
telemetry_events: list[dict[str, Any]],
) -> list[dict[str, Any]]:
@ -114,6 +123,7 @@ async def test_teleport_command_without_history_sends_early_failure_telemetry(
{
"event_name": "vibe.teleport_failed",
"properties": {
**_expected_system_metadata(),
"stage": "no_history",
"error_class": "TeleportNoHistoryError",
"push_required": False,
@ -156,6 +166,7 @@ async def test_teleport_command_visible_but_errors_when_key_not_eligible(
{
"event_name": "vibe.teleport_failed",
"properties": {
**_expected_system_metadata(),
"stage": "ineligible",
"error_class": "TeleportIneligibleError",
"push_required": False,

View file

@ -1,12 +1,17 @@
from __future__ import annotations
from pathlib import Path
import pytest
from textual.content import Content
from textual.highlight import HighlightTheme
from textual.widget import Widget
from vibe.cli.textual_ui.widgets.diff_rendering import (
DiffOccurrence,
_build_diff_line,
diff_border_colors,
edit_diff_inputs,
language_for_path,
render_edit_diff,
)
@ -20,14 +25,12 @@ def _build(
)
def _render(*args, **kwargs):
kwargs.setdefault("dark", True)
args = list(args)
# Tests pass a single start line as an int for readability; the renderer
# expects a list of occurrences.
if len(args) >= 4 and isinstance(args[3], int):
args[3] = [args[3]]
return render_edit_diff(*args, **kwargs)
def _render(old_string, new_string, language, start, *, ansi, dark=True):
# Tests pass the same old/new at a single start line (int/None) or, for
# replace_all, at a list of start lines; build one occurrence per location.
starts = start if isinstance(start, list) else [start]
occurrences = [DiffOccurrence(s, old_string, new_string) for s in starts]
return render_edit_diff(occurrences, language, ansi=ansi, dark=dark)
def _render_with_colors(*args, **kwargs):
@ -80,14 +83,20 @@ class TestBuildDiffLine:
styles = _styles_at(content, 0)
assert "$text-success" in styles
assert all("dim" not in s for s in styles)
assert all("bold" not in s for s in styles)
def test_removed_line_number_and_sign_bright_in_ansi(self) -> None:
def test_removed_line_number_not_bold_in_ansi(self) -> None:
content = _build("x = 1", "-", 10, "py", ansi=True)
lineno_styles = _styles_at(content, 0)
sign_styles = _styles_at(content, 5)
assert any("bold" in s and "$text-error" in s for s in lineno_styles)
assert any("bold" in s and "$text-error" in s for s in sign_styles)
assert any("$text-error" in s for s in lineno_styles)
assert all("bold" not in s for s in lineno_styles)
assert all("dim" not in s for s in lineno_styles)
def test_removed_sign_not_bold_in_ansi(self) -> None:
content = _build("x = 1", "-", 10, "py", ansi=True)
sign_styles = _styles_at(content, 5)
assert any("$text-error" in s for s in sign_styles)
assert all("bold" not in s for s in sign_styles)
assert all("dim" not in s for s in sign_styles)
def test_line_number_dimmed_for_unchanged_rows_in_ansi(self) -> None:
@ -139,6 +148,26 @@ class TestRenderEditDiff:
widgets = _render("x = 100", "x = 200", "py", 42, ansi=False)
assert any("42" in _plain(w) for w in widgets)
@pytest.mark.asyncio
async def test_leading_newline_snippet_gutter_matches_file_lines(
self, tmp_path: Path
) -> None:
# A leading-newline snippet starts modifying the previous line; the
# whole-line diff must show that line and number the hunk against the
# real file lines, not skip the leading newline and drift by one.
f = tmp_path / "f.py"
f.write_text("aa\nbab\ncc\n")
occurrences = await edit_diff_inputs(
str(f), "\nbab\nc", "Z\nbab\nC", replace_all=False
)
widgets = render_edit_diff(occurrences, "py", ansi=False, dark=True)
file_lines = ["aa", "bab", "cc"]
for w in widgets:
plain = _plain(w)
if plain[5:6] in (" ", "-"):
lineno = int(plain[:4])
assert plain[7:] == file_lines[lineno - 1]
def test_multi_hunk_line_numbers(self) -> None:
search = "A\nB\nC\nD\nE\nF\nG\nH"
replace = "Z\nB\nC\nD\nE\nF\nG\nY"
@ -164,31 +193,45 @@ class TestRenderEditDiff:
assert any(_plain(w).rstrip().endswith("Z") for w in added)
def test_replace_all_renders_each_occurrence(self) -> None:
widgets = render_edit_diff(
"foo", "bar", "py", [3, 10, 25], ansi=False, dark=True
)
widgets = _render("foo", "bar", "py", [3, 10, 25], ansi=False)
removed = [w for w in widgets if "diff-removed" in w.classes]
added = [w for w in widgets if "diff-added" in w.classes]
assert len(removed) == 3
assert len(added) == 3
def test_replace_all_uses_each_start_line(self) -> None:
widgets = render_edit_diff(
"foo", "bar", "py", [3, 10, 25], ansi=False, dark=True
)
widgets = _render("foo", "bar", "py", [3, 10, 25], ansi=False)
joined = "\n".join(_plain(w) for w in widgets)
assert "3" in joined
assert "10" in joined
assert "25" in joined
def test_replace_all_separates_occurrences_with_gap(self) -> None:
widgets = render_edit_diff("foo", "bar", "py", [3, 10], ansi=False, dark=True)
widgets = _render("foo", "bar", "py", [3, 10], ansi=False)
assert sum("diff-gap" in w.classes for w in widgets) == 1
def test_single_occurrence_has_no_gap(self) -> None:
widgets = render_edit_diff("foo", "bar", "py", [3], ansi=False, dark=True)
widgets = _render("foo", "bar", "py", [3], ansi=False)
assert all("diff-gap" not in w.classes for w in widgets)
def test_per_occurrence_full_lines(self) -> None:
# Each occurrence carries its own whole-line content with its own line.
widgets = render_edit_diff(
[
DiffOccurrence(2, "x = bar + 1", "x = qux + 1"),
DiffOccurrence(7, "y = bar - 2", "y = qux - 2"),
],
"py",
ansi=False,
dark=True,
)
removed = [_plain(w) for w in widgets if "diff-removed" in w.classes]
added = [_plain(w) for w in widgets if "diff-added" in w.classes]
assert any("x = bar + 1" in r for r in removed)
assert any("y = bar - 2" in r for r in removed)
assert any("x = qux + 1" in a for a in added)
assert any("y = qux - 2" in a for a in added)
class TestBorderColors:
def test_keys_index_into_widgets(self) -> None:

View file

@ -49,12 +49,23 @@ def _call_event() -> ToolCallEvent:
)
def _error_result() -> ToolResultEvent:
def _error_result(error: str = "boom") -> ToolResultEvent:
return ToolResultEvent(
tool_name="stub_tool",
tool_class=FakeTool,
result=None,
error="boom",
error=error,
tool_call_id="a",
)
def _skipped_result() -> ToolResultEvent:
return ToolResultEvent(
tool_name="stub_tool",
tool_class=FakeTool,
result=None,
skipped=True,
skip_reason="User declined",
tool_call_id="a",
)
@ -87,6 +98,40 @@ async def test_error_renders_muted_then_escalates_icon_and_styling() -> None:
assert not result_widget.has_class("error-text")
@pytest.mark.asyncio
async def test_declined_call_renders_muted_square() -> None:
app = _ToolApp(_call_event(), _skipped_result())
async with app.run_test() as pilot:
await pilot.pause()
call_widget = app.call_widget
assert call_widget is not None
icon = call_widget._indicator_widget
assert icon is not None
assert _rendered(icon).plain == ""
assert icon.has_class("muted")
assert not icon.has_class("error")
@pytest.mark.asyncio
async def test_error_with_square_brackets_does_not_raise_markup_error() -> None:
error = (
"Validation error in tool ask_user_question: 1 validation error for "
"AskUserQuestionArgs\nquestions.0.header\n Value error "
"[type=value_error, input_value={'questions[0].header': 'x'}, "
"input_type=dict]"
)
app = _ToolApp(_call_event(), _error_result(error))
async with app.run_test() as pilot:
await pilot.pause()
result_widget = app.result_widget
assert result_widget is not None
detail = result_widget.query_one(CollapsibleSection).query_one(Static)
content = _rendered(detail)
assert content.plain == f"Error: {error}"
@pytest.mark.asyncio
async def test_folded_error_detail_colors_only_the_error_word() -> None:
app = _ToolApp(_call_event(), _error_result())