from __future__ import annotations from collections.abc import Iterator from pathlib import Path from git import Repo import httpx import pytest import respx from tests.agent_loop.e2e.conftest import build_e2e_agent_loop from tests.constants import ( CONNECTORS_BOOTSTRAP_PATH, MISTRAL_BASE_URL, TELEPORT_COMPLETE_URL, TELEPORT_SESSIONS_PATH, ) from vibe.core.agent_loop import AgentLoop, TeleportError from vibe.core.teleport.types import ( TeleportCheckingGitEvent, TeleportCompleteEvent, TeleportPushingEvent, TeleportPushRequiredEvent, TeleportPushResponseEvent, TeleportStartingWorkflowEvent, ) # vibe_code_sessions_base_url is an exclude=True field, so it is dropped when the # 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}" def _sessions_ok() -> httpx.Response: return httpx.Response( 200, json={ "sessionId": "controller-session-id", "webSessionId": "web-session-id", "projectId": "project-id", "status": "running", "url": TELEPORT_COMPLETE_URL, }, ) 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) 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. 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") return repo def _repo_with_pushed_branch(workdir: Path) -> Repo: repo = _init_repo(workdir) _commit(repo, "initial") repo.git.push("origin", "work") return repo @pytest.fixture def mock_sessions() -> Iterator[respx.MockRouter]: # One router stubs both hosts: the connector bootstrap (api.mistral.ai, hit on # agent init) and the teleport sessions endpoint (chat.mistral.ai). with respx.mock(assert_all_called=False) as router: router.get(f"{MISTRAL_BASE_URL}{CONNECTORS_BOOTSTRAP_PATH}").mock( return_value=httpx.Response(200, json={"connectors": []}) ) router.post(SESSIONS_URL).mock(return_value=_sessions_ok()) yield router async def _drain( agent: AgentLoop, prompt: str | None, *, approve: bool | None = None ) -> list[object]: gen = agent.teleport_to_vibe_code(prompt) events: list[object] = [] response: TeleportPushResponseEvent | None = None while True: try: event = await gen.asend(response) except StopAsyncIteration: break events.append(event) response = None if isinstance(event, TeleportPushRequiredEvent) and approve is not None: response = TeleportPushResponseEvent(approved=approve) return events @pytest.mark.asyncio async def test_teleport_completes_when_branch_already_pushed( tmp_working_directory: Path, mock_sessions: respx.MockRouter ) -> None: _repo_with_pushed_branch(tmp_working_directory) events = await _drain(build_e2e_agent_loop(), "do the thing") assert [type(e) for e in events] == [ TeleportCheckingGitEvent, TeleportStartingWorkflowEvent, TeleportCompleteEvent, ] assert isinstance(events[-1], TeleportCompleteEvent) assert events[-1].url == TELEPORT_COMPLETE_URL @pytest.mark.asyncio async def test_teleport_sends_repo_metadata_and_diff( tmp_working_directory: Path, mock_sessions: respx.MockRouter ) -> None: repo = _repo_with_pushed_branch(tmp_working_directory) commit = repo.head.commit.hexsha (tmp_working_directory / "file.txt").write_text("uncommitted change\n") await _drain(build_e2e_agent_loop(), "ship it") payload = mock_sessions.calls.last.request.read().decode() assert "https://github.com/owner/repo.git" in payload assert "work" in payload assert commit in payload assert "zstd" in payload @pytest.mark.asyncio async def test_teleport_pushes_then_completes_when_approved( tmp_working_directory: Path, mock_sessions: respx.MockRouter ) -> None: repo = _repo_with_pushed_branch(tmp_working_directory) head = _commit(repo, "second") events = await _drain(build_e2e_agent_loop(), "ship it", approve=True) assert [type(e) for e in events] == [ TeleportCheckingGitEvent, TeleportPushRequiredEvent, TeleportPushingEvent, TeleportStartingWorkflowEvent, TeleportCompleteEvent, ] assert repo.remote("origin").refs["work"].commit.hexsha == head @pytest.mark.asyncio async def test_teleport_aborts_when_push_declined( tmp_working_directory: Path, mock_sessions: respx.MockRouter ) -> None: repo = _repo_with_pushed_branch(tmp_working_directory) _commit(repo, "second") with pytest.raises(TeleportError, match="not pushed"): await _drain(build_e2e_agent_loop(), "ship it", approve=False) assert not mock_sessions.post(SESSIONS_URL).called @pytest.mark.asyncio async def test_teleport_fails_when_push_fails( tmp_working_directory: Path, mock_sessions: respx.MockRouter ) -> None: repo = _repo_with_pushed_branch(tmp_working_directory) _commit(repo, "second") repo.git.remote("set-url", "--push", "origin", "/nonexistent/repo.git") with pytest.raises(TeleportError, match="Failed to push"): await _drain(build_e2e_agent_loop(), "ship it", approve=True) @pytest.mark.asyncio async def test_teleport_requires_a_branch( tmp_working_directory: Path, mock_sessions: respx.MockRouter ) -> None: repo = _repo_with_pushed_branch(tmp_working_directory) repo.git.checkout(repo.head.commit.hexsha) # detach HEAD with pytest.raises(TeleportError, match="checked-out branch"): await _drain(build_e2e_agent_loop(), "ship it") @pytest.mark.asyncio async def test_teleport_rejects_empty_prompt( tmp_working_directory: Path, mock_sessions: respx.MockRouter ) -> None: repo = _init_repo(tmp_working_directory) _commit(repo, "initial") with pytest.raises(TeleportError, match="non-empty prompt"): await _drain(build_e2e_agent_loop(), None) @pytest.mark.asyncio async def test_teleport_surfaces_http_error( tmp_working_directory: Path, mock_sessions: respx.MockRouter ) -> None: _repo_with_pushed_branch(tmp_working_directory) mock_sessions.post(SESSIONS_URL).mock(return_value=httpx.Response(500, text="boom")) with pytest.raises(TeleportError, match="start failed"): await _drain(build_e2e_agent_loop(), "ship it") @pytest.mark.asyncio async def test_teleport_unsupported_without_github_remote( tmp_working_directory: Path, mock_sessions: respx.MockRouter ) -> None: repo = Repo.init(tmp_working_directory, initial_branch="work") repo.config_writer().set_value("user", "name", "Tester").release() repo.config_writer().set_value("user", "email", "t@example.com").release() _commit(repo, "initial") with pytest.raises(TeleportError, match="GitHub"): await _drain(build_e2e_agent_loop(), "ship it")