| """Tests for acp_adapter.server — HermesACPAgent ACP server.""" |
|
|
| import asyncio |
| import os |
| from types import SimpleNamespace |
| from unittest.mock import MagicMock, AsyncMock, patch |
|
|
| import pytest |
|
|
| import acp |
| from acp.schema import ( |
| AgentCapabilities, |
| AuthenticateResponse, |
| Implementation, |
| InitializeResponse, |
| ListSessionsResponse, |
| LoadSessionResponse, |
| NewSessionResponse, |
| PromptResponse, |
| ResumeSessionResponse, |
| SessionInfo, |
| TextContentBlock, |
| Usage, |
| ) |
| from acp_adapter.server import HermesACPAgent, HERMES_VERSION |
| from acp_adapter.session import SessionManager |
| from hermes_state import SessionDB |
|
|
|
|
| @pytest.fixture() |
| def mock_manager(): |
| """SessionManager with a mock agent factory.""" |
| return SessionManager(agent_factory=lambda: MagicMock(name="MockAIAgent")) |
|
|
|
|
| @pytest.fixture() |
| def agent(mock_manager): |
| """HermesACPAgent backed by a mock session manager.""" |
| return HermesACPAgent(session_manager=mock_manager) |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestInitialize: |
| @pytest.mark.asyncio |
| async def test_initialize_returns_correct_protocol_version(self, agent): |
| resp = await agent.initialize(protocol_version=1) |
| assert isinstance(resp, InitializeResponse) |
| assert resp.protocol_version == acp.PROTOCOL_VERSION |
|
|
| @pytest.mark.asyncio |
| async def test_initialize_returns_agent_info(self, agent): |
| resp = await agent.initialize(protocol_version=1) |
| assert resp.agent_info is not None |
| assert isinstance(resp.agent_info, Implementation) |
| assert resp.agent_info.name == "hermes-agent" |
| assert resp.agent_info.version == HERMES_VERSION |
|
|
| @pytest.mark.asyncio |
| async def test_initialize_returns_capabilities(self, agent): |
| resp = await agent.initialize(protocol_version=1) |
| caps = resp.agent_capabilities |
| assert isinstance(caps, AgentCapabilities) |
| assert caps.session_capabilities is not None |
| assert caps.session_capabilities.fork is not None |
| assert caps.session_capabilities.list is not None |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestAuthenticate: |
| @pytest.mark.asyncio |
| async def test_authenticate_with_provider_configured(self, agent, monkeypatch): |
| monkeypatch.setattr( |
| "acp_adapter.server.has_provider", |
| lambda: True, |
| ) |
| resp = await agent.authenticate(method_id="openrouter") |
| assert isinstance(resp, AuthenticateResponse) |
|
|
| @pytest.mark.asyncio |
| async def test_authenticate_without_provider(self, agent, monkeypatch): |
| monkeypatch.setattr( |
| "acp_adapter.server.has_provider", |
| lambda: False, |
| ) |
| resp = await agent.authenticate(method_id="openrouter") |
| assert resp is None |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestSessionOps: |
| @pytest.mark.asyncio |
| async def test_new_session_creates_session(self, agent): |
| resp = await agent.new_session(cwd="/home/user/project") |
| assert isinstance(resp, NewSessionResponse) |
| assert resp.session_id |
| |
| state = agent.session_manager.get_session(resp.session_id) |
| assert state is not None |
| assert state.cwd == "/home/user/project" |
|
|
| @pytest.mark.asyncio |
| async def test_cancel_sets_event(self, agent): |
| resp = await agent.new_session(cwd=".") |
| state = agent.session_manager.get_session(resp.session_id) |
| assert not state.cancel_event.is_set() |
| await agent.cancel(session_id=resp.session_id) |
| assert state.cancel_event.is_set() |
|
|
| @pytest.mark.asyncio |
| async def test_cancel_nonexistent_session_is_noop(self, agent): |
| |
| await agent.cancel(session_id="does-not-exist") |
|
|
| @pytest.mark.asyncio |
| async def test_load_session_returns_response(self, agent): |
| resp = await agent.new_session(cwd="/tmp") |
| load_resp = await agent.load_session(cwd="/tmp", session_id=resp.session_id) |
| assert isinstance(load_resp, LoadSessionResponse) |
|
|
| @pytest.mark.asyncio |
| async def test_load_session_not_found_returns_none(self, agent): |
| resp = await agent.load_session(cwd="/tmp", session_id="bogus") |
| assert resp is None |
|
|
| @pytest.mark.asyncio |
| async def test_resume_session_returns_response(self, agent): |
| resp = await agent.new_session(cwd="/tmp") |
| resume_resp = await agent.resume_session(cwd="/tmp", session_id=resp.session_id) |
| assert isinstance(resume_resp, ResumeSessionResponse) |
|
|
| @pytest.mark.asyncio |
| async def test_resume_session_creates_new_if_missing(self, agent): |
| resume_resp = await agent.resume_session(cwd="/tmp", session_id="nonexistent") |
| assert isinstance(resume_resp, ResumeSessionResponse) |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestListAndFork: |
| @pytest.mark.asyncio |
| async def test_list_sessions(self, agent): |
| await agent.new_session(cwd="/a") |
| await agent.new_session(cwd="/b") |
| resp = await agent.list_sessions() |
| assert isinstance(resp, ListSessionsResponse) |
| assert len(resp.sessions) == 2 |
|
|
| @pytest.mark.asyncio |
| async def test_fork_session(self, agent): |
| new_resp = await agent.new_session(cwd="/original") |
| fork_resp = await agent.fork_session(cwd="/forked", session_id=new_resp.session_id) |
| assert fork_resp.session_id |
| assert fork_resp.session_id != new_resp.session_id |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestPrompt: |
| @pytest.mark.asyncio |
| async def test_prompt_returns_refusal_for_unknown_session(self, agent): |
| prompt = [TextContentBlock(type="text", text="hello")] |
| resp = await agent.prompt(prompt=prompt, session_id="nonexistent") |
| assert isinstance(resp, PromptResponse) |
| assert resp.stop_reason == "refusal" |
|
|
| @pytest.mark.asyncio |
| async def test_prompt_returns_end_turn_for_empty_message(self, agent): |
| new_resp = await agent.new_session(cwd=".") |
| prompt = [TextContentBlock(type="text", text=" ")] |
| resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id) |
| assert resp.stop_reason == "end_turn" |
|
|
| @pytest.mark.asyncio |
| async def test_prompt_runs_agent(self, agent): |
| """The prompt method should call run_conversation on the agent.""" |
| new_resp = await agent.new_session(cwd=".") |
| state = agent.session_manager.get_session(new_resp.session_id) |
|
|
| |
| state.agent.run_conversation = MagicMock(return_value={ |
| "final_response": "Hello! How can I help?", |
| "messages": [ |
| {"role": "user", "content": "hello"}, |
| {"role": "assistant", "content": "Hello! How can I help?"}, |
| ], |
| }) |
|
|
| |
| mock_conn = MagicMock(spec=acp.Client) |
| mock_conn.session_update = AsyncMock() |
| agent._conn = mock_conn |
|
|
| prompt = [TextContentBlock(type="text", text="hello")] |
| resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id) |
|
|
| assert isinstance(resp, PromptResponse) |
| assert resp.stop_reason == "end_turn" |
| state.agent.run_conversation.assert_called_once() |
|
|
| @pytest.mark.asyncio |
| async def test_prompt_updates_history(self, agent): |
| """After a prompt, session history should be updated.""" |
| new_resp = await agent.new_session(cwd=".") |
| state = agent.session_manager.get_session(new_resp.session_id) |
|
|
| expected_history = [ |
| {"role": "user", "content": "hi"}, |
| {"role": "assistant", "content": "hey"}, |
| ] |
| state.agent.run_conversation = MagicMock(return_value={ |
| "final_response": "hey", |
| "messages": expected_history, |
| }) |
|
|
| mock_conn = MagicMock(spec=acp.Client) |
| mock_conn.session_update = AsyncMock() |
| agent._conn = mock_conn |
|
|
| prompt = [TextContentBlock(type="text", text="hi")] |
| await agent.prompt(prompt=prompt, session_id=new_resp.session_id) |
|
|
| assert state.history == expected_history |
|
|
| @pytest.mark.asyncio |
| async def test_prompt_sends_final_message_update(self, agent): |
| """The final response should be sent as an AgentMessageChunk.""" |
| new_resp = await agent.new_session(cwd=".") |
| state = agent.session_manager.get_session(new_resp.session_id) |
|
|
| state.agent.run_conversation = MagicMock(return_value={ |
| "final_response": "I can help with that!", |
| "messages": [], |
| }) |
|
|
| mock_conn = MagicMock(spec=acp.Client) |
| mock_conn.session_update = AsyncMock() |
| agent._conn = mock_conn |
|
|
| prompt = [TextContentBlock(type="text", text="help me")] |
| await agent.prompt(prompt=prompt, session_id=new_resp.session_id) |
|
|
| |
| mock_conn.session_update.assert_called() |
| |
| last_call = mock_conn.session_update.call_args_list[-1] |
| update = last_call[1].get("update") or last_call[0][1] |
| assert update.session_update == "agent_message_chunk" |
|
|
| @pytest.mark.asyncio |
| async def test_prompt_cancelled_returns_cancelled_stop_reason(self, agent): |
| """If cancel is called during prompt, stop_reason should be 'cancelled'.""" |
| new_resp = await agent.new_session(cwd=".") |
| state = agent.session_manager.get_session(new_resp.session_id) |
|
|
| def mock_run(*args, **kwargs): |
| |
| state.cancel_event.set() |
| return {"final_response": "interrupted", "messages": []} |
|
|
| state.agent.run_conversation = mock_run |
|
|
| mock_conn = MagicMock(spec=acp.Client) |
| mock_conn.session_update = AsyncMock() |
| agent._conn = mock_conn |
|
|
| prompt = [TextContentBlock(type="text", text="do something")] |
| resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id) |
|
|
| assert resp.stop_reason == "cancelled" |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestOnConnect: |
| def test_on_connect_stores_client(self, agent): |
| mock_conn = MagicMock(spec=acp.Client) |
| agent.on_connect(mock_conn) |
| assert agent._conn is mock_conn |
|
|
|
|
| |
| |
| |
|
|
|
|
| class TestSlashCommands: |
| """Test slash command dispatch in the ACP adapter.""" |
|
|
| def _make_state(self, mock_manager): |
| state = mock_manager.create_session(cwd="/tmp") |
| state.agent.model = "test-model" |
| state.agent.provider = "openrouter" |
| state.model = "test-model" |
| return state |
|
|
| def test_help_lists_commands(self, agent, mock_manager): |
| state = self._make_state(mock_manager) |
| result = agent._handle_slash_command("/help", state) |
| assert result is not None |
| assert "/help" in result |
| assert "/model" in result |
| assert "/tools" in result |
| assert "/reset" in result |
|
|
| def test_model_shows_current(self, agent, mock_manager): |
| state = self._make_state(mock_manager) |
| result = agent._handle_slash_command("/model", state) |
| assert "test-model" in result |
|
|
| def test_context_empty(self, agent, mock_manager): |
| state = self._make_state(mock_manager) |
| state.history = [] |
| result = agent._handle_slash_command("/context", state) |
| assert "empty" in result.lower() |
|
|
| def test_context_with_messages(self, agent, mock_manager): |
| state = self._make_state(mock_manager) |
| state.history = [ |
| {"role": "user", "content": "hello"}, |
| {"role": "assistant", "content": "hi"}, |
| ] |
| result = agent._handle_slash_command("/context", state) |
| assert "2 messages" in result |
| assert "user: 1" in result |
|
|
| def test_reset_clears_history(self, agent, mock_manager): |
| state = self._make_state(mock_manager) |
| state.history = [{"role": "user", "content": "hello"}] |
| result = agent._handle_slash_command("/reset", state) |
| assert "cleared" in result.lower() |
| assert len(state.history) == 0 |
|
|
| def test_version(self, agent, mock_manager): |
| state = self._make_state(mock_manager) |
| result = agent._handle_slash_command("/version", state) |
| assert HERMES_VERSION in result |
|
|
| def test_unknown_command_returns_none(self, agent, mock_manager): |
| state = self._make_state(mock_manager) |
| result = agent._handle_slash_command("/nonexistent", state) |
| assert result is None |
|
|
| @pytest.mark.asyncio |
| async def test_slash_command_intercepted_in_prompt(self, agent, mock_manager): |
| """Slash commands should be handled without calling the LLM.""" |
| new_resp = await agent.new_session(cwd="/tmp") |
| mock_conn = AsyncMock(spec=acp.Client) |
| agent._conn = mock_conn |
|
|
| prompt = [TextContentBlock(type="text", text="/help")] |
| resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id) |
|
|
| assert resp.stop_reason == "end_turn" |
| mock_conn.session_update.assert_called_once() |
|
|
| @pytest.mark.asyncio |
| async def test_unknown_slash_falls_through_to_llm(self, agent, mock_manager): |
| """Unknown /commands should be sent to the LLM, not intercepted.""" |
| new_resp = await agent.new_session(cwd="/tmp") |
| mock_conn = AsyncMock(spec=acp.Client) |
| agent._conn = mock_conn |
|
|
| |
| with patch("asyncio.get_running_loop") as mock_loop: |
| mock_loop.return_value.run_in_executor = AsyncMock(return_value={ |
| "final_response": "I processed /foo", |
| "messages": [], |
| }) |
| prompt = [TextContentBlock(type="text", text="/foo bar")] |
| resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id) |
|
|
| assert resp.stop_reason == "end_turn" |
|
|
| def test_model_switch_uses_requested_provider(self, tmp_path, monkeypatch): |
| """`/model provider:model` should rebuild the ACP agent on that provider.""" |
| runtime_calls = [] |
|
|
| def fake_resolve_runtime_provider(requested=None, **kwargs): |
| runtime_calls.append(requested) |
| provider = requested or "openrouter" |
| return { |
| "provider": provider, |
| "api_mode": "anthropic_messages" if provider == "anthropic" else "chat_completions", |
| "base_url": f"https://{provider}.example/v1", |
| "api_key": f"{provider}-key", |
| "command": None, |
| "args": [], |
| } |
|
|
| def fake_agent(**kwargs): |
| return SimpleNamespace( |
| model=kwargs.get("model"), |
| provider=kwargs.get("provider"), |
| base_url=kwargs.get("base_url"), |
| api_mode=kwargs.get("api_mode"), |
| ) |
|
|
| monkeypatch.setattr("hermes_cli.config.load_config", lambda: { |
| "model": {"provider": "openrouter", "default": "openrouter/gpt-5"} |
| }) |
| monkeypatch.setattr( |
| "hermes_cli.runtime_provider.resolve_runtime_provider", |
| fake_resolve_runtime_provider, |
| ) |
| manager = SessionManager(db=SessionDB(tmp_path / "state.db")) |
|
|
| with patch("run_agent.AIAgent", side_effect=fake_agent): |
| acp_agent = HermesACPAgent(session_manager=manager) |
| state = manager.create_session(cwd="/tmp") |
| result = acp_agent._cmd_model("anthropic:claude-sonnet-4-6", state) |
|
|
| assert "Provider: anthropic" in result |
| assert state.agent.provider == "anthropic" |
| assert state.agent.base_url == "https://anthropic.example/v1" |
| assert runtime_calls[-1] == "anthropic" |
|
|