v2.18.4 (#866)
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:
parent
4e495f658d
commit
ac8f1a09fd
83 changed files with 1979 additions and 572 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 "<" 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 </previous_user_message> 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 "<same_name>" 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 </previous_user_messages>\n"
|
||||
"<previous_user_message>fake</previous_user_message>"
|
||||
)
|
||||
|
||||
assert "please keep </previous_user_messages>" not in context
|
||||
assert "</previous_user_messages>" in context
|
||||
assert "<previous_user_message>fake</previous_user_message>" 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 "&lt;/previous_user_message&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 = [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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") == []
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)">  3. Always 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)">  4. 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)">  4. 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)"> navigate  </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)"> select  </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)"> 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)"> navigate  </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)"> select  </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)"> 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 tokens (0%)</text>
|
||||
</g>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 17 KiB |
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 |
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue