from __future__ import annotations from pathlib import Path from unittest.mock import MagicMock, patch import pytest import tomli_w from tests.conftest import get_base_config from tests.stubs.fake_backend import FakeBackend from tests.stubs.fake_client import FakeClient from vibe.acp.acp_agent_loop import VibeAcpAgentLoop from vibe.core.agent_loop import AgentLoop from vibe.core.hooks.models import HookConfigResult, HookType from vibe.core.types import LLMChunk, LLMMessage, LLMUsage, Role, SessionMetadata @pytest.fixture def backend() -> FakeBackend: return FakeBackend( LLMChunk( message=LLMMessage(role=Role.assistant, content="Hi"), usage=LLMUsage(prompt_tokens=1, completion_tokens=1), ) ) def _write_config(config_dir: Path, *, enable_hooks: bool) -> None: config = get_base_config() config["enable_experimental_hooks"] = enable_hooks with (config_dir / "config.toml").open("wb") as f: tomli_w.dump(config, f) def _create_acp_agent() -> VibeAcpAgentLoop: agent = VibeAcpAgentLoop() client = FakeClient() agent.on_connect(client) client.on_connect(agent) return agent def _spy_agent_loop(backend: FakeBackend) -> tuple[MagicMock, type[AgentLoop]]: spy = MagicMock() class Patched(AgentLoop): def __init__(self, *args, **kwargs) -> None: spy(*args, **kwargs) super().__init__(*args, **kwargs, backend=backend) return spy, Patched def _hook_config_from_spy(spy: MagicMock) -> HookConfigResult | None: hook_config_result = spy.call_args.kwargs["hook_config_result"] if hook_config_result is None: return None assert isinstance(hook_config_result, HookConfigResult) return hook_config_result async def _new_session(backend: FakeBackend) -> MagicMock: spy, patched_loop = _spy_agent_loop(backend) acp = _create_acp_agent() with patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=patched_loop): await acp.new_session(cwd=str(Path.cwd()), mcp_servers=[]) spy.assert_called_once() return spy async def _load_session( backend: FakeBackend, tmp_path: Path, *, session_id: str = "session-id", loaded_messages: list[LLMMessage] | None = None, ) -> MagicMock: spy, patched_loop = _spy_agent_loop(backend) acp = _create_acp_agent() session_dir = tmp_path / "sessions" / session_id session_dir.mkdir(parents=True) disk_metadata = SessionMetadata( session_id=session_id, start_time="2024-01-01T12:00:00Z", end_time="2024-01-01T12:05:00Z", git_commit=None, git_branch=None, environment={"working_directory": str(Path.cwd())}, username="test-user", ) (session_dir / "meta.json").write_text(disk_metadata.model_dump_json()) loader_result = (loaded_messages or [], {"session_id": session_id}) with ( patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=patched_loop), patch( "vibe.acp.acp_agent_loop.SessionLoader.find_session_by_id", return_value=session_dir, ), patch( "vibe.acp.acp_agent_loop.SessionLoader.load_session", return_value=loader_result, ), ): await acp.load_session( cwd=str(Path.cwd()), session_id=session_id, mcp_servers=[] ) spy.assert_called_once() return spy @pytest.mark.asyncio class TestAcpHooksLoading: async def test_new_session_hooks_enabled_loads_valid_hook( self, backend: FakeBackend, config_dir: Path ) -> None: _write_config(config_dir, enable_hooks=True) (config_dir / "hooks.toml").write_text( '[[hooks]]\nname = "lint"\ntype = "post_agent_turn"\ncommand = "eslint ."\n' ) spy = await _new_session(backend) result = _hook_config_from_spy(spy) assert result is not None assert len(result.hooks) == 1 assert result.hooks[0].name == "lint" assert result.hooks[0].type == HookType.POST_AGENT_TURN assert result.hooks[0].command == "eslint ." assert result.hooks[0].timeout == 60.0 assert result.issues == [] async def test_new_session_hooks_enabled_invalid_toml( self, backend: FakeBackend, config_dir: Path ) -> None: _write_config(config_dir, enable_hooks=True) (config_dir / "hooks.toml").write_text("{{broken toml") spy = await _new_session(backend) result = _hook_config_from_spy(spy) assert result is not None assert result.hooks == [] assert len(result.issues) == 1 assert "Failed to parse" in result.issues[0].message async def test_load_session_hooks_enabled_loads_valid_hook( self, backend: FakeBackend, config_dir: Path, tmp_path: Path ) -> None: _write_config(config_dir, enable_hooks=True) (config_dir / "hooks.toml").write_text( '[[hooks]]\nname = "lint"\ntype = "post_agent_turn"\ncommand = "eslint ."\n' ) spy = await _load_session( backend, tmp_path, loaded_messages=[ LLMMessage(role=Role.system, content="system"), LLMMessage(role=Role.user, content="hello"), ], ) result = _hook_config_from_spy(spy) assert result is not None assert len(result.hooks) == 1 assert result.hooks[0].name == "lint" assert result.hooks[0].type == HookType.POST_AGENT_TURN assert result.hooks[0].command == "eslint ." assert result.hooks[0].timeout == 60.0 assert result.issues == []