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

@ -72,7 +72,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.3"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.4"
)
assert response.auth_methods is not None
@ -172,7 +172,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.3"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.4"
)
assert response.auth_methods is not None

View file

@ -62,6 +62,53 @@ def acp_agent_with_session_config(
return vibe_acp_agent, client
@pytest.mark.asyncio
async def test_load_session_honors_default_agent(
backend: FakeBackend,
temp_session_dir: Path,
create_test_session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_config = SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="session", enabled=True
)
config = build_test_vibe_config(
default_agent=BuiltinAgentName.PLAN,
models=[
ModelConfig(
name="devstral-latest", provider="mistral", alias="devstral-latest"
)
],
session_logging=session_config,
)
class PatchedAgentLoop(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **{**kwargs, "backend": backend})
self._base_config = config
self.agent_manager.invalidate_config()
monkeypatch.setattr("vibe.acp.acp_agent_loop.AgentLoop", PatchedAgentLoop)
monkeypatch.setattr(VibeAcpAgentLoop, "_load_config", lambda self: config)
vibe_acp_agent = VibeAcpAgentLoop()
client = FakeClient()
vibe_acp_agent.on_connect(client)
client.on_connect(vibe_acp_agent)
session_id = "test-sess-12345678"
cwd = str(Path.cwd())
create_test_session(temp_session_dir, session_id, cwd)
response = await vibe_acp_agent.load_session(
cwd=cwd, mcp_servers=[], session_id=session_id
)
assert response is not None
assert response.modes is not None
assert response.modes.current_mode_id == BuiltinAgentName.PLAN
class TestLoadSession:
@pytest.mark.asyncio
async def test_load_session_response_structure(

View file

@ -365,3 +365,35 @@ class TestACPNewSession:
assert session_response.models is not None
assert session_response.models.current_model_id == "devstral-small"
@pytest.mark.asyncio
async def test_new_session_honors_default_agent(
backend, monkeypatch: pytest.MonkeyPatch
) -> None:
config = build_test_vibe_config(
default_agent=BuiltinAgentName.PLAN,
models=[
ModelConfig(
name="devstral-latest", provider="mistral", alias="devstral-latest"
)
],
)
class PatchedAgentLoop(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **{**kwargs, "backend": backend})
self._base_config = config
self.agent_manager.invalidate_config()
monkeypatch.setattr("vibe.acp.acp_agent_loop.AgentLoop", PatchedAgentLoop)
monkeypatch.setattr(VibeAcpAgentLoop, "_load_config", lambda self: config)
acp_agent_loop = _create_acp_agent()
session_response = await acp_agent_loop.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
assert session_response.modes is not None
assert session_response.modes.current_mode_id == BuiltinAgentName.PLAN

View file

@ -29,6 +29,7 @@ from vibe.core.teleport.types import (
# agent manager re-derives config via model_dump(); the default url is unavoidable.
SESSIONS_BASE_URL = "https://chat.mistral.ai"
SESSIONS_URL = f"{SESSIONS_BASE_URL}{TELEPORT_SESSIONS_PATH}"
GITHUB_REMOTE_URL = "https://github.com/owner/repo.git"
def _sessions_ok() -> httpx.Response:
@ -46,27 +47,28 @@ def _sessions_ok() -> httpx.Response:
def _commit(repo: Repo, message: str) -> str:
(Path(repo.working_dir) / "file.txt").write_text(f"{message}\n")
repo.index.add(["file.txt"])
repo.index.commit(message)
repo.git.add("file.txt")
repo.git.commit("-m", message)
return repo.head.commit.hexsha
def _init_repo(workdir: Path) -> Repo:
# origin is a local bare repo so fetch/push stay offline and instant; a
# separate github-url remote satisfies Teleport's GitHub-only detection.
# origin is intentionally not GitHub. The GitHub-looking `hub` remote is
# rewritten to a local bare repo so fetch/push stay offline and instant.
bare = Repo.init(workdir.with_name(f"{workdir.name}_origin.git"), bare=True)
repo = Repo.init(workdir, initial_branch="work")
repo.config_writer().set_value("user", "name", "Tester").release()
repo.config_writer().set_value("user", "email", "t@example.com").release()
repo.create_remote("origin", str(bare.git_dir))
repo.create_remote("hub", "https://github.com/owner/repo.git")
repo.git.config(f"url.{bare.git_dir}.insteadOf", GITHUB_REMOTE_URL)
repo.create_remote("hub", GITHUB_REMOTE_URL)
return repo
def _repo_with_pushed_branch(workdir: Path) -> Repo:
repo = _init_repo(workdir)
_commit(repo, "initial")
repo.git.push("origin", "work")
repo.git.push("hub", "work")
return repo
@ -150,7 +152,7 @@ async def test_teleport_pushes_then_completes_when_approved(
TeleportStartingWorkflowEvent,
TeleportCompleteEvent,
]
assert repo.remote("origin").refs["work"].commit.hexsha == head
assert repo.remote("hub").refs["work"].commit.hexsha == head
@pytest.mark.asyncio
@ -172,7 +174,7 @@ async def test_teleport_fails_when_push_fails(
) -> None:
repo = _repo_with_pushed_branch(tmp_working_directory)
_commit(repo, "second")
repo.git.remote("set-url", "--push", "origin", "/nonexistent/repo.git")
repo.git.remote("set-url", "--push", "hub", "/nonexistent/repo.git")
with pytest.raises(TeleportError, match="Failed to push"):
await _drain(build_e2e_agent_loop(), "ship it", approve=True)

View file

@ -40,6 +40,18 @@ def _get_auto_compact_properties(
return cast(dict[str, object], auto_compact[0]["properties"])
def _get_compaction_failed_properties(
telemetry_events: list[dict[str, object]],
) -> dict[str, object]:
failed = [
event
for event in telemetry_events
if event.get("event_name") == "vibe.compaction_failed"
]
assert len(failed) == 1
return cast(dict[str, object], failed[0]["properties"])
@pytest.mark.asyncio
async def test_auto_compact_emits_correct_events(telemetry_events: list[dict]) -> None:
backend = FakeBackend([
@ -306,7 +318,9 @@ async def test_compact_without_extra_instructions_has_no_additional_section() ->
@pytest.mark.asyncio
async def test_compact_raises_on_tool_call_when_flag_enabled() -> None:
async def test_compact_raises_on_tool_call_when_flag_enabled(
telemetry_events: list[dict],
) -> None:
"""With the flag on, a compaction that returns a tool call raises."""
backend = FakeBackend([
[
@ -333,10 +347,13 @@ async def test_compact_raises_on_tool_call_when_flag_enabled() -> None:
with pytest.raises(CompactionFailedError) as exc_info:
await agent.compact()
assert exc_info.value.reason == "tool_call"
assert _get_compaction_failed_properties(telemetry_events)["reason"] == "tool_call"
@pytest.mark.asyncio
async def test_compact_raises_on_empty_summary_when_flag_enabled() -> None:
async def test_compact_raises_on_empty_summary_when_flag_enabled(
telemetry_events: list[dict],
) -> None:
"""With the flag on, a compaction with empty content raises."""
backend = FakeBackend([[mock_llm_chunk(content=" ")]])
cfg = build_test_vibe_config(
@ -350,11 +367,20 @@ async def test_compact_raises_on_empty_summary_when_flag_enabled() -> None:
with pytest.raises(CompactionFailedError) as exc_info:
await agent.compact()
assert exc_info.value.reason == "empty_summary"
assert (
_get_compaction_failed_properties(telemetry_events)["reason"] == "empty_summary"
)
@pytest.mark.asyncio
async def test_compact_falls_back_when_flag_disabled() -> None:
"""With the flag off (default), empty content uses the legacy fallback."""
async def test_compact_falls_back_when_flag_disabled(
telemetry_events: list[dict],
) -> None:
"""With the flag off (default), empty content uses the legacy fallback.
The compaction failure telemetry event must still be sent even though the
flag is off and compact() returns normally.
"""
backend = FakeBackend([[mock_llm_chunk(content="")]])
cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=999))
agent = build_test_agent_loop(config=cfg, backend=backend)
@ -363,6 +389,9 @@ async def test_compact_falls_back_when_flag_disabled() -> None:
summary = await agent.compact()
assert summary == "(no summary available)"
assert (
_get_compaction_failed_properties(telemetry_events)["reason"] == "empty_summary"
)
@pytest.mark.asyncio

View file

@ -17,9 +17,10 @@ from tests.conftest import (
)
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe import __version__
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
from vibe.core.telemetry.types import EntrypointMetadata
from vibe.core.telemetry.types import LaunchContext, TerminalEmulator
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import (
Backend,
@ -32,6 +33,7 @@ from vibe.core.types import (
StopInfo,
ToolCall,
)
from vibe.core.utils import get_platform_id, get_platform_version
def _two_model_vibe_config(active_model: str) -> VibeConfig:
@ -191,19 +193,20 @@ async def test_passes_parent_session_id_to_backend_after_reset(vibe_config: Vibe
@pytest.mark.asyncio
async def test_passes_entrypoint_metadata_to_backend(vibe_config: VibeConfig):
metadata = EntrypointMetadata(
async def test_passes_launch_context_to_backend(vibe_config: VibeConfig):
launch_context = LaunchContext(
agent_entrypoint="acp",
agent_version="2.0.0",
client_name="vibe_ide",
client_version="0.5.0",
terminal_emulator=TerminalEmulator.GHOSTTY,
)
backend = FakeBackend([mock_llm_chunk(content="Response")])
agent = build_test_agent_loop(
config=vibe_config,
backend=backend,
enable_streaming=True,
entrypoint_metadata=metadata,
launch_context=launch_context,
)
[_ async for _ in agent.act("Hello")]
@ -215,6 +218,13 @@ async def test_passes_entrypoint_metadata_to_backend(vibe_config: VibeConfig):
assert meta["agent_version"] == "2.0.0"
assert meta["client_name"] == "vibe_ide"
assert meta["client_version"] == "0.5.0"
assert meta["os"] == get_platform_id()
if os_version := get_platform_version():
assert meta["os_version"] == os_version
else:
assert "os_version" not in meta
assert meta["version"] == __version__
assert meta["terminal_emulator"] == TerminalEmulator.GHOSTTY
assert meta["session_id"] == agent.session_id
assert "message_id" in meta
assert meta["call_type"] == "main_call"
@ -272,11 +282,12 @@ async def test_mcp_sampling_handler_uses_updated_config_when_agent_config_change
@pytest.mark.asyncio
async def test_mcp_sampling_handler_sends_secondary_call_telemetry_metadata():
metadata = EntrypointMetadata(
launch_context = LaunchContext(
agent_entrypoint="acp",
agent_version="2.0.0",
client_name="vibe_ide",
client_version="0.5.0",
terminal_emulator=TerminalEmulator.GHOSTTY,
)
backend = FakeBackend([
[mock_llm_chunk(content="Response")],
@ -285,7 +296,7 @@ async def test_mcp_sampling_handler_sends_secondary_call_telemetry_metadata():
agent = build_test_agent_loop(
config=_two_model_vibe_config("devstral-latest"),
backend=backend,
entrypoint_metadata=metadata,
launch_context=launch_context,
)
[_ async for _ in agent.act("Hello")]
@ -302,6 +313,13 @@ async def test_mcp_sampling_handler_sends_secondary_call_telemetry_metadata():
assert sampling_metadata["agent_version"] == "2.0.0"
assert sampling_metadata["client_name"] == "vibe_ide"
assert sampling_metadata["client_version"] == "0.5.0"
assert sampling_metadata["os"] == get_platform_id()
if os_version := get_platform_version():
assert sampling_metadata["os_version"] == os_version
else:
assert "os_version" not in sampling_metadata
assert sampling_metadata["version"] == __version__
assert sampling_metadata["terminal_emulator"] == TerminalEmulator.GHOSTTY
assert sampling_metadata["session_id"] == agent.session_id
assert sampling_metadata["parent_session_id"] == "parent-session-456"
assert sampling_metadata["message_id"] == next(

View file

@ -344,6 +344,15 @@ class TestAgentProfileOverrides:
overrides = BUILTIN_AGENTS[BuiltinAgentName.AUTO_APPROVE].overrides
assert overrides.get("bypass_tool_permissions") is True
def test_lean_agent_keeps_tool_permissions_configurable(self) -> None:
overrides = BUILTIN_AGENTS[BuiltinAgentName.LEAN].overrides
assert "bypass_tool_permissions" not in overrides
def test_lean_agent_uses_latest_model(self) -> None:
overrides = BUILTIN_AGENTS[BuiltinAgentName.LEAN].overrides
models = overrides["models"]
assert models[0]["name"] == "labs-leanstral-1-5"
def test_plan_agent_restricts_tools(self) -> None:
overrides = BUILTIN_AGENTS[BuiltinAgentName.PLAN].overrides
assert "tools" in overrides

View file

@ -16,7 +16,7 @@ from tests.stubs.fake_mcp_registry import FakeMCPRegistry
from vibe.core import agent_loop as agent_loop_module
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import MCPStdio
from vibe.core.telemetry.types import TerminalEmulator
from vibe.core.telemetry.types import LaunchContext, TerminalEmulator
from vibe.core.tools.manager import ToolManager
from vibe.core.tools.mcp import AuthStatus
from vibe.core.tools.remote import RemoteTool
@ -371,7 +371,15 @@ class TestStartInitializeExperiments:
@pytest.mark.asyncio
async def test_refreshes_system_prompt_when_experiments_update(self) -> None:
loop = build_test_agent_loop(terminal_emulator=TerminalEmulator.VSCODE)
loop = build_test_agent_loop(
launch_context=LaunchContext(
agent_entrypoint="cli",
agent_version="1.0.0",
client_name="vibe_cli",
client_version="1.0.0",
terminal_emulator=TerminalEmulator.VSCODE,
)
)
refresh_mock = AsyncMock()
init_mock = AsyncMock(return_value=True)
@ -387,21 +395,31 @@ class TestStartInitializeExperiments:
init_mock.assert_awaited_once()
init_args = init_mock.await_args
assert init_args is not None
assert init_args.kwargs["terminal_emulator"] is TerminalEmulator.VSCODE
assert (
init_args.kwargs["launch_context"].terminal_emulator
is TerminalEmulator.VSCODE
)
def test_new_session_telemetry_uses_provided_terminal_emulator(self) -> None:
loop = build_test_agent_loop(terminal_emulator=TerminalEmulator.VSCODE)
send_new_session = MagicMock()
loop = build_test_agent_loop(
launch_context=LaunchContext(
agent_entrypoint="cli",
agent_version="1.0.0",
client_name="vibe_cli",
client_version="1.0.0",
terminal_emulator=TerminalEmulator.VSCODE,
)
)
send_event = MagicMock()
with patch.object(
loop.telemetry_client, "send_new_session", new=send_new_session
loop.telemetry_client, "send_telemetry_event", new=send_event
):
loop.emit_new_session_telemetry()
assert (
send_new_session.call_args.kwargs["terminal_emulator"]
is TerminalEmulator.VSCODE
)
payload = send_event.call_args.args[1]
assert payload["terminal_emulator"] == "vscode"
assert type(payload["terminal_emulator"]) is str
@pytest.mark.asyncio
async def test_does_not_refresh_system_prompt_when_experiments_unchanged(

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())

View file

@ -12,7 +12,7 @@ from vibe.core.experiments.session import (
hydrate_experiments_from_session,
initialize_experiments,
)
from vibe.core.telemetry.types import TerminalEmulator
from vibe.core.telemetry.types import LaunchContext, TerminalEmulator
class _StubClient(RemoteEvalClient):
@ -50,7 +50,7 @@ async def test_initialize_returns_false_when_telemetry_disabled(
config=_make_config(enable_telemetry=False),
manager=manager,
session_logger=session_logger,
entrypoint_metadata=None,
launch_context=None,
)
assert result is False
@ -73,7 +73,7 @@ async def test_initialize_returns_false_when_experiments_disabled(
config=_make_config(enable_experiments=False),
manager=manager,
session_logger=session_logger,
entrypoint_metadata=None,
launch_context=None,
)
assert result is False
@ -97,7 +97,7 @@ async def test_initialize_returns_false_when_no_mistral_provider(
config=_make_config(),
manager=manager,
session_logger=session_logger,
entrypoint_metadata=None,
launch_context=None,
)
assert result is False
@ -131,7 +131,7 @@ async def test_initialize_returns_false_when_remote_eval_fails(
config=_make_config(),
manager=manager,
session_logger=session_logger,
entrypoint_metadata=None,
launch_context=None,
)
assert result is False
@ -164,7 +164,7 @@ async def test_initialize_returns_true_and_persists_when_remote_eval_succeeds(
config=_make_config(),
manager=manager,
session_logger=session_logger,
entrypoint_metadata=None,
launch_context=None,
)
assert result is True
@ -192,8 +192,13 @@ async def test_initialize_uses_provided_terminal_emulator(
config=_make_config(),
manager=manager,
session_logger=session_logger,
entrypoint_metadata=None,
terminal_emulator=TerminalEmulator.VSCODE,
launch_context=LaunchContext(
agent_entrypoint="cli",
agent_version="1.0.0",
client_name="vibe_cli",
client_version="1.0.0",
terminal_emulator=TerminalEmulator.VSCODE,
),
)
assert result is True

View file

@ -1,8 +1,19 @@
from __future__ import annotations
from tests.conftest import build_test_vibe_config
from vibe import __version__
from vibe.core.experiments.active import ExperimentName
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.utils import get_platform_id, get_platform_version
def _assert_system_metadata(metadata: dict[str, object]) -> None:
assert metadata["os"] == get_platform_id()
assert metadata["version"] == __version__
if os_version := get_platform_version():
assert metadata["os_version"] == os_version
else:
assert "os_version" not in metadata
def test_build_client_event_metadata_omits_experiments_when_empty() -> None:
@ -12,6 +23,7 @@ def test_build_client_event_metadata_omits_experiments_when_empty() -> None:
)
metadata = client.build_client_event_metadata()
assert "experiments" not in metadata
_assert_system_metadata(metadata)
def test_build_client_event_metadata_includes_experiments_when_present() -> None:
@ -22,6 +34,7 @@ def test_build_client_event_metadata_includes_experiments_when_present() -> None
)
metadata = client.build_client_event_metadata()
assert metadata["experiments"] == {sp_key: "1"}
_assert_system_metadata(metadata)
def test_build_client_event_metadata_works_without_getter() -> None:
@ -29,3 +42,4 @@ def test_build_client_event_metadata_works_without_getter() -> None:
client = TelemetryClient(config_getter=lambda: config)
metadata = client.build_client_event_metadata()
assert "experiments" not in metadata
_assert_system_metadata(metadata)

View file

@ -26,6 +26,41 @@ def test_refresh_config_reconciles_mcp_registry_status(
assert registry.status() == {"kept": AuthStatus.STATIC}
def test_refresh_config_preserves_forced_bypass_tool_permissions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# A session-level forced bypass (e.g. CLI --yolo) must survive a config
# reload, which reads bypass_tool_permissions=False back from disk.
agent_loop = build_test_agent_loop(
config=build_test_vibe_config(bypass_tool_permissions=True),
force_bypass_tool_permissions=True,
)
assert agent_loop.bypass_tool_permissions is True
refreshed_config = build_test_vibe_config(bypass_tool_permissions=False)
monkeypatch.setattr(VibeConfig, "load", staticmethod(lambda: refreshed_config))
agent_loop.refresh_config()
assert agent_loop.bypass_tool_permissions is True
def test_refresh_config_drops_disk_bypass_when_not_forced(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Without a forced override, a disk-originated bypass value follows the
# reloaded config so the user can turn it off by editing their config.
agent_loop = build_test_agent_loop(
config=build_test_vibe_config(bypass_tool_permissions=True)
)
assert agent_loop.bypass_tool_permissions is True
refreshed_config = build_test_vibe_config(bypass_tool_permissions=False)
monkeypatch.setattr(VibeConfig, "load", staticmethod(lambda: refreshed_config))
agent_loop.refresh_config()
assert agent_loop.bypass_tool_permissions is False
def test_refresh_config_does_not_mark_undiscovered_oauth_server_ok(
monkeypatch: pytest.MonkeyPatch,
) -> None:

View file

@ -93,14 +93,60 @@ def test_compaction_context_merges_previous_and_new_user_messages() -> None:
assert all(m.injected for m in out)
def test_compaction_context_escapes_user_message_tags() -> None:
original = "please keep </previous_user_message_0> literally"
def test_compaction_context_preserves_normal_angle_brackets() -> None:
original = "theorem <same_name> : ¬ (T) := by"
context = render_compaction_context([_user(original)], "summary")
assert "</previous_user_message_0> literally" not in context
assert "&lt;" not in context
assert f"<previous_user_message>\n{original}\n</previous_user_message>" in context
assert parse_previous_user_messages(context) == [original]
def test_compaction_context_escapes_reserved_user_message_tags() -> None:
original = "please keep </previous_user_message> and <same_name> literally"
context = render_compaction_context([_user(original)], "summary")
escaped = "please keep &lt;/previous_user_message&gt; and <same_name> literally"
assert "please keep </previous_user_message> and" not in context
assert (f"<previous_user_message>\n{escaped}\n</previous_user_message>") in context
assert "&lt;same_name&gt;" not in context
assert parse_previous_user_messages(context) == [escaped]
def test_compaction_context_escapes_outer_tags_in_user_message() -> None:
original = (
"please keep </previous_user_messages>\n"
"<previous_user_message>fake</previous_user_message>"
)
context = render_compaction_context([_user(original)], "summary")
escaped = (
"please keep &lt;/previous_user_messages&gt;\n"
"&lt;previous_user_message&gt;fake&lt;/previous_user_message&gt;"
)
assert "please keep </previous_user_messages>" not in context
assert "&lt;/previous_user_messages&gt;" in context
assert "&lt;previous_user_message&gt;fake&lt;/previous_user_message&gt;" in context
assert parse_previous_user_messages(context) == [escaped]
def test_compaction_context_does_not_double_escape_reserved_tags() -> None:
original = "please keep </previous_user_message> literally"
first_context = render_compaction_context([_user(original)], "summary")
preserved = parse_previous_user_messages(first_context)
second_context = render_compaction_context([_user(preserved[0])], "summary")
assert "&amp;lt;/previous_user_message&amp;gt;" not in second_context
assert parse_previous_user_messages(second_context) == preserved
def test_compaction_context_preserves_summary_angle_brackets() -> None:
context = render_compaction_context([_user("hello")], "summary with <code>")
assert "summary with <code>" in context
def test_budget_drops_oldest_first() -> None:
# max_tokens=2 → 8 char budget. Walks newest-first, so "old" gets dropped.
messages = [

View file

@ -193,3 +193,21 @@ def test_add_oauth_mcp_server_persists_http_transport() -> None:
def test_parse_mcp_add_transport_rejects_unsupported_transport() -> None:
with pytest.raises(MCPServerAddError, match="http, streamable-http"):
parse_mcp_add_transport("sse")
def test_vibe_config_rejects_duplicate_mcp_server_names() -> None:
with pytest.raises(ValueError, match="Duplicate MCP server name found: 'figma'"):
VibeConfig.model_validate({
"mcp_servers": [
{
"name": "figma",
"transport": "streamable-http",
"url": "https://a.example.com/mcp",
},
{
"name": "figma",
"transport": "streamable-http",
"url": "https://b.example.com/mcp",
},
]
})

View file

@ -10,6 +10,7 @@ import pytest
from tests.conftest import build_test_vibe_config
from tests.stubs.fake_tool import FakeTool, FakeToolArgs
from vibe import __version__
from vibe.core.agent_loop import ToolDecision, ToolExecutionResponse
from vibe.core.llm.format import ResolvedToolCall
from vibe.core.telemetry.build_metadata import (
@ -19,13 +20,13 @@ from vibe.core.telemetry.build_metadata import (
from vibe.core.telemetry.send import TelemetryClient, _extract_file_extension
from vibe.core.telemetry.types import (
AttachmentKind,
EntrypointMetadata,
LaunchContext,
TelemetryRequestMetadata,
TerminalEmulator,
)
from vibe.core.tools.base import BaseTool, ToolPermission
from vibe.core.types import Backend
from vibe.core.utils import get_user_agent
from vibe.core.utils import get_platform_id, get_platform_version, get_user_agent
_original_send_telemetry_event = TelemetryClient.send_telemetry_event
from vibe.core.tools.builtins.edit import Edit, EditArgs
@ -70,6 +71,32 @@ def _run_telemetry_tasks() -> None:
loop.close()
def _expected_system_metadata(
terminal_emulator: TerminalEmulator | None = None,
) -> dict[str, Any]:
metadata: dict[str, Any] = {"os": get_platform_id(), "version": __version__}
if os_version := get_platform_version():
metadata["os_version"] = os_version
if terminal_emulator is not None:
metadata["terminal_emulator"] = terminal_emulator
return metadata
def _assert_system_metadata(
properties: dict[str, Any], terminal_emulator: TerminalEmulator | None = None
) -> None:
assert properties["os"] == get_platform_id()
assert properties["version"] == __version__
if os_version := get_platform_version():
assert properties["os_version"] == os_version
else:
assert "os_version" not in properties
if terminal_emulator is None:
assert "terminal_emulator" not in properties
return
assert properties["terminal_emulator"] == terminal_emulator
class TestExtractFileExtension:
@pytest.mark.parametrize(
("path", "expected"),
@ -164,7 +191,10 @@ class TestTelemetryClient:
mock_post.assert_called_once_with(
"https://api.mistral.ai/v1/datalake/events",
json={"event": "vibe.test_event", "properties": {"key": "value"}},
json={
"event": "vibe.test_event",
"properties": {**_expected_system_metadata(), "key": "value"},
},
headers={
"Content-Type": "application/json",
"Authorization": "Bearer sk-test",
@ -380,6 +410,7 @@ class TestTelemetryClient:
assert len(telemetry_events) == 1
assert telemetry_events[0]["event_name"] == "vibe.at_mention_inserted"
assert telemetry_events[0]["properties"] == {
**_expected_system_metadata(),
"nb_mentions": 2,
"context_types": {"file": 1, "folder": 1},
"file_extensions": {".py": 1},
@ -452,11 +483,45 @@ class TestTelemetryClient:
assert len(telemetry_events) == 1
assert telemetry_events[0]["event_name"] == "vibe.auto_compact_triggered"
assert telemetry_events[0]["properties"] == {
**_expected_system_metadata(),
"nb_context_tokens_before": 123,
"auto_compact_threshold": 100,
"status": "success",
}
def test_send_compaction_failed_payload(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
client.send_compaction_failed(reason="tool_call")
assert len(telemetry_events) == 1
assert telemetry_events[0]["event_name"] == "vibe.compaction_failed"
assert telemetry_events[0]["properties"] == {
**_expected_system_metadata(),
"reason": "tool_call",
}
def test_send_compaction_failed_includes_session_ids(
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
client.send_compaction_failed(
reason="empty_summary",
session_id="session-id",
parent_session_id="parent-session-id",
)
assert len(telemetry_events) == 1
properties = telemetry_events[0]["properties"]
assert properties["reason"] == "empty_summary"
assert properties["session_id"] == "session-id"
assert properties["parent_session_id"] == "parent-session-id"
def test_send_slash_command_used_payload(
self, telemetry_events: list[dict[str, Any]]
) -> None:
@ -484,6 +549,7 @@ class TestTelemetryClient:
assert len(telemetry_events) == 1
assert telemetry_events[0]["event_name"] == "vibe.teleport_completed"
assert telemetry_events[0]["properties"] == {
**_expected_system_metadata(),
"push_required": True,
"nb_session_messages": 4,
}
@ -504,6 +570,7 @@ class TestTelemetryClient:
assert len(telemetry_events) == 1
assert telemetry_events[0]["event_name"] == "vibe.teleport_failed"
assert telemetry_events[0]["properties"] == {
**_expected_system_metadata(),
"stage": "push",
"error_class": "ServiceTeleportError",
"push_required": True,
@ -526,6 +593,7 @@ class TestTelemetryClient:
assert telemetry_events[0]["event_name"] == "vibe.teleport_failed"
assert telemetry_events[0]["properties"] == {
**_expected_system_metadata(),
"stage": "workflow_start",
"error_class": "ServiceTeleportError",
"push_required": False,
@ -538,17 +606,19 @@ class TestTelemetryClient:
self, telemetry_events: list[dict[str, Any]]
) -> None:
config = build_test_vibe_config(enable_telemetry=True)
client = TelemetryClient(config_getter=lambda: config)
client = TelemetryClient(
config_getter=lambda: config,
launch_context=LaunchContext(
agent_entrypoint="cli",
agent_version=__version__,
client_name="vscode",
client_version="1.96.0",
terminal_emulator=TerminalEmulator.VSCODE,
),
)
client.send_new_session(
has_agents_md=True,
nb_skills=2,
nb_mcp_servers=1,
nb_models=3,
entrypoint="cli",
client_name="vscode",
client_version="1.96.0",
terminal_emulator=TerminalEmulator.VSCODE,
has_agents_md=True, nb_skills=2, nb_mcp_servers=1, nb_models=3
)
assert len(telemetry_events) == 1
@ -563,7 +633,8 @@ class TestTelemetryClient:
assert properties["client_name"] == "vscode"
assert properties["client_version"] == "1.96.0"
assert properties["terminal_emulator"] == "vscode"
assert "version" in properties
assert type(properties["terminal_emulator"]) is str
_assert_system_metadata(properties, TerminalEmulator.VSCODE)
@pytest.mark.asyncio
async def test_send_session_closed_payload(
@ -579,11 +650,12 @@ class TestTelemetryClient:
config_getter=lambda: config,
session_id_getter=lambda: "current-session",
parent_session_id_getter=lambda: "current-parent-session",
entrypoint_metadata_getter=lambda: EntrypointMetadata(
launch_context=LaunchContext(
agent_entrypoint="cli",
agent_version="1.0.0",
client_name="vibe_cli",
client_version="1.0.0",
terminal_emulator=TerminalEmulator.VSCODE,
),
)
mock_post = AsyncMock(return_value=MagicMock(status_code=204))
@ -599,6 +671,7 @@ class TestTelemetryClient:
json={
"event": "vibe.session_closed",
"properties": {
**_expected_system_metadata(TerminalEmulator.VSCODE),
"agent_entrypoint": "cli",
"agent_version": "1.0.0",
"client_name": "vibe_cli",
@ -616,17 +689,19 @@ class TestTelemetryClient:
def test_build_base_metadata_includes_entrypoint_and_session(self) -> None:
metadata = build_base_metadata(
entrypoint_metadata=EntrypointMetadata(
launch_context=LaunchContext(
agent_entrypoint="cli",
agent_version="1.0.0",
client_name="vibe_cli",
client_version="1.0.0",
terminal_emulator=TerminalEmulator.VSCODE,
),
session_id="session-123",
parent_session_id="parent-session-456",
)
assert metadata == {
**_expected_system_metadata(TerminalEmulator.VSCODE),
"agent_entrypoint": "cli",
"agent_version": "1.0.0",
"client_name": "vibe_cli",
@ -634,14 +709,16 @@ class TestTelemetryClient:
"session_id": "session-123",
"parent_session_id": "parent-session-456",
}
assert type(metadata["terminal_emulator"]) is str
def test_build_request_metadata_includes_all_telemetry_metadata(self) -> None:
metadata = build_request_metadata(
entrypoint_metadata=EntrypointMetadata(
launch_context=LaunchContext(
agent_entrypoint="cli",
agent_version="1.0.0",
client_name="vibe_cli",
client_version="1.0.0",
terminal_emulator=TerminalEmulator.VSCODE,
),
session_id="session-123",
parent_session_id="parent-session-456",
@ -650,6 +727,7 @@ class TestTelemetryClient:
)
assert metadata == TelemetryRequestMetadata(
**_expected_system_metadata(TerminalEmulator.VSCODE),
agent_entrypoint="cli",
agent_version="1.0.0",
client_name="vibe_cli",
@ -691,6 +769,7 @@ class TestTelemetryClient:
json={
"event": "vibe.test_event",
"properties": {
**_expected_system_metadata(),
"session_id": "session-123",
"parent_session_id": "parent-session-456",
"key": "value",
@ -729,7 +808,11 @@ class TestTelemetryClient:
"https://api.mistral.ai/v1/datalake/events",
json={
"event": "vibe.test_event",
"properties": {"session_id": session_id, "key": "value"},
"properties": {
**_expected_system_metadata(),
"session_id": session_id,
"key": "value",
},
},
headers={
"Content-Type": "application/json",
@ -759,7 +842,10 @@ class TestTelemetryClient:
mock_post.assert_called_once_with(
"https://api.mistral.ai/v1/datalake/events",
json={"event": "vibe.test_event", "properties": {"key": "value"}},
json={
"event": "vibe.test_event",
"properties": {**_expected_system_metadata(), "key": "value"},
},
headers={
"Content-Type": "application/json",
"Authorization": "Bearer sk-test",
@ -828,7 +914,9 @@ class TestTelemetryClient:
assert len(telemetry_events) == 1
assert telemetry_events[0]["event_name"] == "vibe.ready"
assert telemetry_events[0]["properties"]["init_duration_ms"] == 1240
properties = telemetry_events[0]["properties"]
assert properties["init_duration_ms"] == 1240
_assert_system_metadata(properties)
def test_send_request_sent_payload(
self, telemetry_events: list[dict[str, Any]]
@ -855,6 +943,7 @@ class TestTelemetryClient:
assert properties["call_type"] == "main_call"
assert properties["message_id"] is None
assert properties["attachment_counts"] == {}
_assert_system_metadata(properties)
def test_send_request_sent_payload_with_attachments(
self, telemetry_events: list[dict[str, Any]]

View file

@ -13,14 +13,16 @@ from vibe.core.teleport.errors import (
from vibe.core.teleport.git import GitRepoInfo, GitRepository
def make_mock_remote(url: str) -> MagicMock:
def make_mock_remote(url: str, name: str = "origin") -> MagicMock:
remote = MagicMock()
remote.name = name
remote.urls = [url]
return remote
def make_mock_repo(
urls: list[str] | None = None,
remote_names: list[str] | None = None,
commit: str | None = "abc123",
branch: str | None = "main",
is_detached: bool = False,
@ -28,7 +30,13 @@ def make_mock_repo(
) -> MagicMock:
mock = MagicMock()
if urls:
mock.remotes = [make_mock_remote(url) for url in urls]
names = remote_names or [
"origin" if index == 0 else f"remote-{index}"
for index, _ in enumerate(urls)
]
mock.remotes = [
make_mock_remote(url, names[index]) for index, url in enumerate(urls)
]
else:
mock.remotes = []
mock.head.commit.hexsha = commit
@ -184,6 +192,7 @@ class TestGitRepositoryGetInfo:
with patch.object(repo, "_repo_or_raise", return_value=mock):
info = await repo.get_info()
assert info == GitRepoInfo(
remote_name="origin",
remote_url="https://github.com/owner/repo.git",
owner="owner",
repo="repo",
@ -192,6 +201,19 @@ class TestGitRepositoryGetInfo:
diff="diff content",
)
@pytest.mark.asyncio
async def test_returns_matched_github_remote_name(
self, repo: GitRepository
) -> None:
mock = make_mock_repo(
urls=["git@gitlab.com:owner/repo.git", "git@github.com:owner/repo.git"],
remote_names=["origin", "hub"],
)
with patch.object(repo, "_repo_or_raise", return_value=mock):
info = await repo.get_info()
assert info.remote_name == "hub"
assert info.remote_url == "https://github.com/owner/repo.git"
@pytest.mark.asyncio
async def test_handles_detached_head(self, repo: GitRepository) -> None:
mock = make_mock_repo(

View file

@ -218,3 +218,96 @@ async def test_start_raises_for_invalid_json_response() -> None:
"failure_kind": "invalid_json",
"http_status_code": 200,
}
@pytest.mark.asyncio
async def test_start_retries_ambiguous_gateway_timeout_with_same_request() -> None:
seen_bodies: list[dict[str, object]] = []
async def handler(request: httpx.Request) -> httpx.Response:
seen_bodies.append(json.loads(request.content))
if len(seen_bodies) == 1:
return httpx.Response(504, text="Gateway timeout")
return httpx.Response(
200,
json={
"sessionId": "controller-session-id",
"webSessionId": "web-session-id",
"projectId": "project-id",
"status": "running",
"url": "https://chat.example.com/code/project-id/web-session-id",
},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
nuage = NuageClient(
"https://chat.example.com",
"api-key",
client=client,
retry_delay_seconds=0.0,
)
response = await nuage.start(_request())
assert response.status == "running"
assert [body["idempotencyKey"] for body in seen_bodies] == ["idem-1", "idem-1"]
@pytest.mark.asyncio
async def test_start_retries_timeout_with_same_request() -> None:
seen_bodies: list[dict[str, object]] = []
async def handler(request: httpx.Request) -> httpx.Response:
seen_bodies.append(json.loads(request.content))
if len(seen_bodies) == 1:
raise httpx.ReadTimeout("read timed out", request=request)
return httpx.Response(
200,
json={
"sessionId": "controller-session-id",
"webSessionId": "web-session-id",
"projectId": "project-id",
"status": "running",
"url": "https://chat.example.com/code/project-id/web-session-id",
},
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
nuage = NuageClient(
"https://chat.example.com",
"api-key",
client=client,
retry_delay_seconds=0.0,
)
response = await nuage.start(_request())
assert response.status == "running"
assert [body["idempotencyKey"] for body in seen_bodies] == ["idem-1", "idem-1"]
@pytest.mark.asyncio
async def test_start_raises_retry_guidance_after_ambiguous_create_exhausted() -> None:
calls = 0
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
return httpx.Response(504, text="Gateway timeout")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
nuage = NuageClient(
"https://chat.example.com",
"api-key",
client=client,
max_start_attempts=2,
retry_delay_seconds=0.0,
)
with pytest.raises(
ServiceTeleportError, match="did not confirm session creation"
) as exc_info:
await nuage.start(_request())
assert calls == 2
assert exc_info.value.telemetry_details == {
"failure_kind": "ambiguous_create",
"http_status_code": 504,
}

View file

@ -119,6 +119,7 @@ class TestTeleportServiceCheckSupported:
) -> None:
service._git.get_info = AsyncMock(
return_value=GitRepoInfo(
remote_name="origin",
remote_url="https://github.com/owner/repo.git",
owner="owner",
repo="repo",
@ -171,6 +172,7 @@ class TestTeleportServiceExecute:
request = service._build_nuage_request(
prompt="test prompt",
git_info=GitRepoInfo(
remote_name="origin",
remote_url="https://github.com/owner/repo",
owner="owner",
repo="repo",
@ -192,6 +194,7 @@ class TestTeleportServiceExecute:
request = service._build_nuage_request(
prompt="test prompt",
git_info=GitRepoInfo(
remote_name="origin",
remote_url="https://github.com/owner/repo",
owner="owner",
repo="repo",
@ -232,6 +235,7 @@ class TestTeleportServiceExecute:
service._git.fetch = AsyncMock()
service._git.get_info = AsyncMock(
return_value=GitRepoInfo(
remote_name="upstream",
remote_url="https://github.com/owner/repo",
owner="owner",
repo="repo",
@ -265,6 +269,13 @@ class TestTeleportServiceExecute:
assert repos[0]["diff"]["compression"] == "zstd"
assert len(repos[0]["diff"]["content"]) > 0
assert "idempotencyKey" in seen_body
service._git.fetch.assert_awaited_once_with("upstream")
service._git.is_commit_pushed.assert_awaited_once_with(
"abc123", remote="upstream", fetch=False
)
service._git.is_branch_pushed.assert_awaited_once_with(
remote="upstream", fetch=False
)
@pytest.mark.asyncio
async def test_execute_requires_branch(self, tmp_path: Path) -> None:
@ -272,6 +283,7 @@ class TestTeleportServiceExecute:
service._git.fetch = AsyncMock()
service._git.get_info = AsyncMock(
return_value=GitRepoInfo(
remote_name="origin",
remote_url="https://github.com/owner/repo",
owner="owner",
repo="repo",
@ -303,6 +315,7 @@ class TestTeleportServiceExecute:
service._git.fetch = AsyncMock()
service._git.get_info = AsyncMock(
return_value=GitRepoInfo(
remote_name="github",
remote_url="https://github.com/owner/repo",
owner="owner",
repo="repo",
@ -328,7 +341,8 @@ class TestTeleportServiceExecute:
)
events = [event async for event in gen]
service._git.push_current_branch.assert_awaited_once()
service._git.get_unpushed_commit_count.assert_awaited_once_with("github")
service._git.push_current_branch.assert_awaited_once_with("github")
assert isinstance(events[0], TeleportStartingWorkflowEvent)
assert isinstance(events[1], TeleportCompleteEvent)
@ -338,6 +352,7 @@ class TestTeleportServiceExecute:
service._git.fetch = AsyncMock()
service._git.get_info = AsyncMock(
return_value=GitRepoInfo(
remote_name="origin",
remote_url="https://github.com/owner/repo",
owner="owner",
repo="repo",

View file

@ -65,11 +65,11 @@ class TestTeleportAgentLoopTelemetry:
assert isinstance(events[-1], TeleportCompleteEvent)
assert telemetry_events[-1]["event_name"] == "vibe.teleport_completed"
assert telemetry_events[-1]["properties"] == {
assert {
"push_required": True,
"nb_session_messages": 1,
"session_id": agent_loop.session_id,
}
}.items() <= telemetry_events[-1]["properties"].items()
@pytest.mark.asyncio
async def test_teleport_to_vibe_code_sends_failed_stage(
@ -101,14 +101,14 @@ class TestTeleportAgentLoopTelemetry:
pass
assert telemetry_events[-1]["event_name"] == "vibe.teleport_failed"
assert telemetry_events[-1]["properties"] == {
assert {
"stage": "workflow_start",
"error_class": "ServiceTeleportError",
"push_required": False,
"nb_session_messages": 1,
"http_status_code": 502,
"session_id": agent_loop.session_id,
}
}.items() <= telemetry_events[-1]["properties"].items()
assert "api-key-123" not in str(telemetry_events[-1]["properties"])
@pytest.mark.asyncio
@ -146,13 +146,13 @@ class TestTeleportAgentLoopTelemetry:
await gen.asend(TeleportPushResponseEvent(approved=False))
assert telemetry_events[-1]["event_name"] == "vibe.teleport_failed"
assert telemetry_events[-1]["properties"] == {
assert {
"stage": "cancelled",
"error_class": "ServiceTeleportError",
"push_required": True,
"nb_session_messages": 1,
"session_id": agent_loop.session_id,
}
}.items() <= telemetry_events[-1]["properties"].items()
@pytest.mark.asyncio
async def test_teleport_to_vibe_code_sends_failed_when_task_cancelled(
@ -181,13 +181,13 @@ class TestTeleportAgentLoopTelemetry:
await gen.asend(None)
assert telemetry_events[-1]["event_name"] == "vibe.teleport_failed"
assert telemetry_events[-1]["properties"] == {
assert {
"stage": "cancelled",
"error_class": "CancelledError",
"push_required": False,
"nb_session_messages": 1,
"session_id": agent_loop.session_id,
}
}.items() <= telemetry_events[-1]["properties"].items()
@pytest.mark.asyncio
async def test_teleport_to_vibe_code_sends_failed_when_consumer_closes_generator(
@ -215,10 +215,10 @@ class TestTeleportAgentLoopTelemetry:
await gen.aclose()
assert telemetry_events[-1]["event_name"] == "vibe.teleport_failed"
assert telemetry_events[-1]["properties"] == {
assert {
"stage": "cancelled",
"error_class": "CancelledError",
"push_required": False,
"nb_session_messages": 1,
"session_id": agent_loop.session_id,
}
}.items() <= telemetry_events[-1]["properties"].items()

View file

@ -1,52 +1,31 @@
from __future__ import annotations
from vibe.core.utils.text import snippet_start_line, snippet_start_lines
from vibe.core.utils.text import line_contexts
class TestSnippetStartLine:
def test_finds_line_number(self) -> None:
assert snippet_start_line("a\nb\nc\nd\n", "c") == 3
def test_first_line(self) -> None:
assert snippet_start_line("hello\nworld", "hello") == 1
def test_multiline_snippet(self) -> None:
assert snippet_start_line("a\nb\nc", "\nb\n") == 2
def test_first_occurrence_when_repeated(self) -> None:
assert snippet_start_line("x\nx\nx", "x") == 1
def test_leading_newline_anchors_first_content_line(self) -> None:
assert snippet_start_line("bar\nx\nbar", "\nbar") == 3
def test_returns_none_when_exact_snippet_absent(self) -> None:
assert snippet_start_line("a\nb\nfoo", "foo\n") is None
def test_not_found(self) -> None:
assert snippet_start_line("hello\nworld", "missing") is None
def test_blank_snippet(self) -> None:
assert snippet_start_line("hello", "\n") is None
class TestSnippetStartLines:
class TestLineContexts:
def test_single_occurrence(self) -> None:
assert snippet_start_lines("a\nb\nc", "b") == [2]
assert line_contexts("foo = bar + baz", "bar") == [(1, "foo = ", " + baz")]
def test_all_occurrences(self) -> None:
assert snippet_start_lines("x\ny\nx\nz\nx", "x") == [1, 3, 5]
def test_per_occurrence_distinct_context(self) -> None:
content = "x = bar + 1\ny = bar - 2\nz = bar\n"
assert line_contexts(content, "bar") == [
(1, "x = ", " + 1"),
(2, "y = ", " - 2"),
(3, "z = ", ""),
]
def test_repeated_on_same_line(self) -> None:
assert snippet_start_lines("x x x", "x") == [1, 1, 1]
def test_snippet_ending_on_newline_has_empty_suffix(self) -> None:
content = "keep1\nremove\nkeep2\n"
assert line_contexts(content, "remove\n") == [(2, "", "")]
def test_non_overlapping(self) -> None:
assert snippet_start_lines("aaaa", "aa") == [1, 1]
def test_multiline_snippet_occurrences(self) -> None:
assert snippet_start_lines("a\nb\nc\na\nb", "a\nb") == [1, 4]
def test_leading_newline_anchors_at_match_position(self) -> None:
# The leading newline belongs to lineA, so the whole-line expansion must
# include lineA (the line the edit starts modifying) anchored at line 1.
assert line_contexts("lineA\nlineB", "\nlineB") == [(1, "lineA", "")]
def test_not_found(self) -> None:
assert snippet_start_lines("hello\nworld", "missing") == []
assert line_contexts("hello\nworld", "missing") == []
def test_blank_snippet(self) -> None:
assert snippet_start_lines("hello", "\n") == []
assert line_contexts("hello", "\n") == []

View file

@ -230,17 +230,18 @@ def test_get_result_display() -> None:
assert "foo.py" in display.message
def test_ui_start_lines_not_part_of_model_contract() -> None:
def test_ui_hints_not_part_of_model_contract() -> None:
result = EditResult(file="/x", message="m", old_string="a", new_string="b")
result._ui_start_lines = [42]
result._ui_occurrences = [(42, "a", "b")]
assert result.ui_start_lines == [42]
assert "ui_start_lines" not in result.model_dump()
assert "_ui_start_lines" not in result.model_dump()
assert "ui_start_lines" not in result.model_dump_json()
assert "ui_start_lines" not in EditResult.model_fields
assert "ui_start_lines" not in EditResult.model_json_schema().get("properties", {})
assert "ui_start_lines" not in dict(result)
assert result.ui_occurrences == [(42, "a", "b")]
for key in ("ui_start_lines", "ui_occurrences", "_ui_occurrences"):
assert key not in result.model_dump()
assert key not in result.model_dump_json()
assert key not in dict(result)
assert "ui_occurrences" not in EditResult.model_fields
assert "ui_occurrences" not in EditResult.model_json_schema().get("properties", {})
@pytest.mark.asyncio
@ -305,3 +306,62 @@ async def test_ui_start_lines_single_entry_without_replace_all(
)
assert result.ui_start_lines == [2]
def test_ui_occurrences_fall_back_to_snippet_when_unset() -> None:
result = EditResult(file="/x", message="m", old_string="a", new_string="b")
assert result.ui_occurrences == [(None, "a", "b")]
@pytest.mark.asyncio
async def test_ui_occurrences_expand_mid_line_snippet(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
_write(tmp_path, "f.txt", "foo = bar + baz\n")
edit = _make_edit()
result = await collect_result(
edit.run(EditArgs(file_path="f.txt", old_string="bar", new_string="qux"))
)
assert result.ui_occurrences == [(1, "foo = bar + baz", "foo = qux + baz")]
@pytest.mark.asyncio
async def test_ui_occurrences_for_pure_deletion(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
_write(tmp_path, "f.txt", "keep1\nkeep2\nremove\nkeep3\n")
edit = _make_edit()
result = await collect_result(
edit.run(EditArgs(file_path="f.txt", old_string="remove\n", new_string=""))
)
assert result.ui_occurrences == [(3, "remove\n", "")]
@pytest.mark.asyncio
async def test_ui_occurrences_use_per_occurrence_lines_for_replace_all(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
_write(tmp_path, "f.txt", "x = bar + 1\ny = bar - 2\nz = bar\n")
edit = _make_edit()
result = await collect_result(
edit.run(
EditArgs(
file_path="f.txt", old_string="bar", new_string="qux", replace_all=True
)
)
)
assert result.ui_occurrences == [
(1, "x = bar + 1", "x = qux + 1"),
(2, "y = bar - 2", "y = qux - 2"),
(3, "z = bar", "z = qux"),
]

View file

@ -35,8 +35,9 @@ from vibe.core.config.harness_files import (
reset_harness_files_manager,
)
from vibe.core.paths import GLOBAL_ENV_FILE, VIBE_HOME
from vibe.core.telemetry.build_metadata import build_entrypoint_metadata
from vibe.core.telemetry.build_metadata import build_launch_context
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.telemetry.types import TerminalEmulator
from vibe.core.types import Backend
from vibe.setup.auth import (
BrowserSignInError,
@ -1273,7 +1274,7 @@ def test_persist_api_key_returns_env_var_error_for_empty_env_var_name() -> None:
assert result == "env_var_error:<empty>"
def test_persist_api_key_sends_onboarding_telemetry_with_entrypoint_metadata(
def test_persist_api_key_sends_onboarding_telemetry_with_launch_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorded_metadata: dict[str, str] = {}
@ -1293,11 +1294,12 @@ def test_persist_api_key_sends_onboarding_telemetry_with_entrypoint_metadata(
result = persist_api_key(
provider,
"secret",
entrypoint_metadata=build_entrypoint_metadata(
launch_context=build_launch_context(
agent_entrypoint="cli",
agent_version="1.0.0",
client_name="vibe_cli",
client_version="1.0.0",
terminal_emulator=TerminalEmulator.APPLE_TERMINAL,
),
)
@ -1306,4 +1308,5 @@ def test_persist_api_key_sends_onboarding_telemetry_with_entrypoint_metadata(
assert recorded_metadata["agent_version"] == "1.0.0"
assert recorded_metadata["client_name"] == "vibe_cli"
assert recorded_metadata["client_version"] == "1.0.0"
assert recorded_metadata["terminal_emulator"] == "apple_terminal"
assert "session_id" not in recorded_metadata

View file

@ -217,6 +217,26 @@ class TestSessionLoaderFindLatestSession:
)
assert result == expected
def test_find_latest_session_matches_unnormalized_stored_cwd(
self, session_config: SessionLoggingConfig, create_test_session, tmp_path: Path
) -> None:
project = tmp_path / "project"
project.mkdir()
unnormalized = project.parent / "sub" / ".." / "project"
expected = create_test_session(
Path(session_config.save_dir),
"unnormalized-session",
working_directory=unnormalized,
)
assert str(unnormalized) != str(project.resolve())
result = SessionLoader.find_latest_session(
session_config, working_directory=project.resolve()
)
assert result == expected
def test_find_latest_session_nonexistent_save_dir(self) -> None:
"""Test finding latest session when save directory doesn't exist."""
# Modify config to point to non-existent directory

View file

@ -37,13 +37,12 @@
.terminal-r3 { fill: #68a0b3 }
.terminal-r4 { fill: #d0b344;font-weight: bold }
.terminal-r5 { fill: #868887 }
.terminal-r6 { fill: #cc555a;font-weight: bold }
.terminal-r6 { fill: #cc555a }
.terminal-r7 { fill: #8d7b39 }
.terminal-r8 { fill: #98a84b }
.terminal-r9 { fill: #d0b344 }
.terminal-r10 { fill: #98a84b;font-weight: bold }
.terminal-r11 { fill: #cc555a }
.terminal-r12 { fill: #6b753d;font-weight: bold }
.terminal-r11 { fill: #6b753d;font-weight: bold }
</style>
<defs>
@ -174,9 +173,9 @@
</text><text class="terminal-r1" x="0" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="1207.8" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)"></text><text class="terminal-r1" x="1220" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
</text><text class="terminal-r1" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="24.4" y="581.2" textLength="207.4" clip-path="url(#terminal-line-23)">&#160;&#160;3.&#160;Always&#160;allow</text><text class="terminal-r1" x="1207.8" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="1220" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r1" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="1207.8" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="1220" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r1" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r11" x="24.4" y="630" textLength="109.8" clip-path="url(#terminal-line-25)">&#160;&#160;4.&#160;Deny</text><text class="terminal-r1" x="1207.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r6" x="24.4" y="630" textLength="109.8" clip-path="url(#terminal-line-25)">&#160;&#160;4.&#160;Deny</text><text class="terminal-r1" x="1207.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1220" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r1" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1207.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r1" x="1220" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r12" x="24.4" y="678.8" textLength="61" clip-path="url(#terminal-line-27)">↑↓/jk</text><text class="terminal-r5" x="85.4" y="678.8" textLength="134.2" clip-path="url(#terminal-line-27)">&#160;navigate&#160;&#160;</text><text class="terminal-r12" x="219.6" y="678.8" textLength="61" clip-path="url(#terminal-line-27)">Enter</text><text class="terminal-r5" x="280.6" y="678.8" textLength="109.8" clip-path="url(#terminal-line-27)">&#160;select&#160;&#160;</text><text class="terminal-r12" x="390.4" y="678.8" textLength="36.6" clip-path="url(#terminal-line-27)">Esc</text><text class="terminal-r5" x="427" y="678.8" textLength="85.4" clip-path="url(#terminal-line-27)">&#160;reject</text><text class="terminal-r1" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="0" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r11" x="24.4" y="678.8" textLength="61" clip-path="url(#terminal-line-27)">↑↓/jk</text><text class="terminal-r5" x="85.4" y="678.8" textLength="134.2" clip-path="url(#terminal-line-27)">&#160;navigate&#160;&#160;</text><text class="terminal-r11" x="219.6" y="678.8" textLength="61" clip-path="url(#terminal-line-27)">Enter</text><text class="terminal-r5" x="280.6" y="678.8" textLength="109.8" clip-path="url(#terminal-line-27)">&#160;select&#160;&#160;</text><text class="terminal-r11" x="390.4" y="678.8" textLength="36.6" clip-path="url(#terminal-line-27)">Esc</text><text class="terminal-r5" x="427" y="678.8" textLength="85.4" clip-path="url(#terminal-line-27)">&#160;reject</text><text class="terminal-r1" x="1207.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"></text><text class="terminal-r1" x="1220" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="0" y="703.2" textLength="1220" clip-path="url(#terminal-line-28)">└──────────────────────────────────────────────────────────────────────────────────────────────────┘</text><text class="terminal-r1" x="1220" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r5" x="0" y="727.6" textLength="158.6" clip-path="url(#terminal-line-29)">/test/workdir</text><text class="terminal-r5" x="1000.4" y="727.6" textLength="219.6" clip-path="url(#terminal-line-29)">0/200k&#160;tokens&#160;(0%)</text>
</g>

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 47 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Before After
Before After

View file

@ -45,9 +45,10 @@ REPLACE_ALL_CONTENT = "\n".join([
" count = count + 1",
" return count",
"",
"def reset():",
" count = 0",
" return count",
"class Counter:",
" def reset(self):",
" count = 0 # start over",
" return count",
])
@ -69,6 +70,28 @@ class EditReplaceAllApprovalApp(BaseSnapshotTestApp):
await self._switch_to_approval_app("edit", args)
LONG_OLD = " message = " + " + ".join(f'"word_{i}"' for i in range(40))
LONG_NEW = " message = " + " + ".join(f'"token_{i}"' for i in range(40))
OVERFLOW_CONTENT = "\n".join(["def build_message():", LONG_OLD, " return message"])
class EditOverflowApprovalApp(BaseSnapshotTestApp):
_diff_theme: str = "tokyo-night"
async def on_ready(self) -> None:
await super().on_ready()
self.theme = self._diff_theme
path = Path("src/message.py")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(OVERFLOW_CONTENT)
args = EditArgs(
file_path="src/message.py",
old_string=f"{LONG_OLD}\n return message",
new_string=f"{LONG_NEW}\n return message.upper()",
)
await self._switch_to_approval_app("edit", args)
def test_snapshot_edit_approval_diff(snap_compare: SnapCompare) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.3)
@ -100,3 +123,16 @@ def test_snapshot_edit_approval_diff_replace_all(snap_compare: SnapCompare) -> N
terminal_size=(100, 30),
run_before=run_before,
)
def test_snapshot_edit_approval_diff_horizontal_overflow(
snap_compare: SnapCompare,
) -> None:
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.3)
assert snap_compare(
"test_ui_snapshot_edit_diff.py:EditOverflowApprovalApp",
terminal_size=(100, 30),
run_before=run_before,
)

View file

@ -8,7 +8,7 @@ from tests.conftest import build_test_vibe_config
from tests.mock.utils import collect_result
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import BUILTIN_AGENTS, AgentType
from vibe.core.telemetry.types import TerminalEmulator
from vibe.core.telemetry.types import LaunchContext, TerminalEmulator
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError, ToolPermission
from vibe.core.tools.builtins.task import Task, TaskArgs, TaskResult, TaskToolConfig
from vibe.core.tools.permissions import PermissionContext
@ -39,7 +39,13 @@ class TestTaskToolValidation:
return InvokeContext(
tool_call_id="test-call-id",
agent_manager=manager,
terminal_emulator=TerminalEmulator.VSCODE,
launch_context=LaunchContext(
agent_entrypoint="cli",
agent_version="1.0.0",
client_name="vibe_cli",
client_version="1.0.0",
terminal_emulator=TerminalEmulator.VSCODE,
),
)
@pytest.mark.asyncio
@ -136,7 +142,13 @@ class TestTaskToolExecution:
return InvokeContext(
tool_call_id="test-call-id",
agent_manager=manager,
terminal_emulator=TerminalEmulator.VSCODE,
launch_context=LaunchContext(
agent_entrypoint="cli",
agent_version="1.0.0",
client_name="vibe_cli",
client_version="1.0.0",
terminal_emulator=TerminalEmulator.VSCODE,
),
)
@pytest.mark.asyncio
@ -170,7 +182,9 @@ class TestTaskToolExecution:
assert result.turns_used == 2 # 2 assistant messages in mock_messages
assert result.completed is True
assert (
mock_agent_loop_class.call_args.kwargs["terminal_emulator"]
mock_agent_loop_class.call_args.kwargs[
"launch_context"
].terminal_emulator
is TerminalEmulator.VSCODE
)