hermes-agent/tests/acp/test_server.py
teknium1 e80786cc94 feat: add ACP (Agent Client Protocol) server for editor integration
Adds full ACP support enabling hermes-agent to work as a coding agent
inside VS Code (via vscode-acp extension), Zed, JetBrains IDEs, and
any ACP-compatible editor.

## New module: acp_adapter/

- server.py: HermesACPAgent implementing all 15 Agent protocol methods
  (initialize, authenticate, new/load/list/fork/resume session, prompt,
  cancel, set mode/model/config, on_connect)
- session.py: Thread-safe SessionManager with per-session AIAgent lifecycle
- events.py: Callback factories translating hermes callbacks to ACP
  session_update notifications (tool_call, agent_thought, agent_message)
- tools.py: Tool kind mapping (20+ tools → read/edit/execute/search/fetch/think)
  and content builders (diffs for file edits, terminal output, text previews)
- permissions.py: Bridges hermes approval_callback to ACP requestPermission
  RPC for dangerous command approval dialogs in the editor
- auth.py: Provider credential verification
- entry.py: CLI entry point with .env loading and stderr logging

## Integration points

- run_agent.py: ACP tool bridge hook in _execute_tool_calls() for
  delegating file/terminal operations to the editor
- hermes_cli/main.py: 'hermes acp' subcommand
- pyproject.toml: [acp] optional dependency, hermes-acp entry point,
  included in [all] extras (auto-installed via install.sh)

## Supporting files

- acp_registry/agent.json: ACP Registry manifest
- acp_registry/icon.svg: Hermes caduceus icon
- docs/acp-setup.md: User-facing setup guide for VS Code, Zed, JetBrains

## Tests

- 41 new tests across 5 test files covering tools, sessions, permissions,
  server lifecycle, and auth
- Full test suite: 2901 passed, 0 failures

## User flow

1. hermes is already installed (install.sh)
2. Install 'ACP Client' extension in VS Code
3. Configure: command='hermes', args=['acp']
4. Chat with Hermes in the editor — diffs, terminals, approval
   dialogs, thinking blocks all rendered natively
2026-03-10 08:41:11 -07:00

103 lines
3.6 KiB
Python

"""Tests for acp_adapter.server — HermesACPAgent ACP server."""
import os
from unittest.mock import MagicMock
import pytest
import acp
from acp.schema import (
AgentCapabilities,
AuthenticateResponse,
Implementation,
InitializeResponse,
NewSessionResponse,
SessionInfo,
)
from acp_adapter.server import HermesACPAgent, HERMES_VERSION
from acp_adapter.session import SessionManager
@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)
# ---------------------------------------------------------------------------
# initialize
# ---------------------------------------------------------------------------
class TestInitialize:
def test_initialize_returns_correct_protocol_version(self, agent):
resp = agent.initialize(protocol_version=1)
assert isinstance(resp, InitializeResponse)
assert resp.protocol_version == acp.PROTOCOL_VERSION
def test_initialize_returns_agent_info(self, agent):
resp = 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
def test_initialize_returns_capabilities(self, agent):
resp = 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
# ---------------------------------------------------------------------------
# authenticate
# ---------------------------------------------------------------------------
class TestAuthenticate:
def test_authenticate_with_provider_configured(self, agent, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-test-123")
resp = agent.authenticate(method_id="openrouter")
assert isinstance(resp, AuthenticateResponse)
def test_authenticate_without_provider(self, agent, monkeypatch):
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
resp = agent.authenticate(method_id="openrouter")
assert resp is None
# ---------------------------------------------------------------------------
# new_session / cancel
# ---------------------------------------------------------------------------
class TestSessionOps:
def test_new_session_creates_session(self, agent):
resp = agent.new_session(cwd="/home/user/project")
assert isinstance(resp, NewSessionResponse)
assert resp.session_id
# Session should be retrievable from the manager
state = agent.session_manager.get_session(resp.session_id)
assert state is not None
assert state.cwd == "/home/user/project"
def test_cancel_sets_event(self, agent):
resp = agent.new_session(cwd=".")
state = agent.session_manager.get_session(resp.session_id)
assert not state.cancel_event.is_set()
agent.cancel(session_id=resp.session_id)
assert state.cancel_event.is_set()
def test_cancel_nonexistent_session_is_noop(self, agent):
# Should not raise
agent.cancel(session_id="does-not-exist")