mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
691 lines
23 KiB
Python
691 lines
23 KiB
Python
"""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.agent.router import build_agent_router
|
|
from acp.schema import (
|
|
AgentCapabilities,
|
|
AgentMessageChunk,
|
|
AgentPlanUpdate,
|
|
AgentThoughtChunk,
|
|
AuthenticateResponse,
|
|
AvailableCommandsUpdate,
|
|
Implementation,
|
|
InitializeResponse,
|
|
LoadSessionResponse,
|
|
NewSessionResponse,
|
|
PromptResponse,
|
|
ResumeSessionResponse,
|
|
SessionModelState,
|
|
SessionModeState,
|
|
SetSessionConfigOptionResponse,
|
|
SetSessionModelResponse,
|
|
SetSessionModeResponse,
|
|
SessionInfo,
|
|
SessionInfoUpdate,
|
|
TextContentBlock,
|
|
ToolCallProgress,
|
|
ToolCallStart,
|
|
UsageUpdate,
|
|
UserMessageChunk,
|
|
)
|
|
from acp_adapter.auth import TERMINAL_SETUP_AUTH_METHOD_ID
|
|
from acp_adapter.server import (
|
|
ACP_MAX_MODELS_PER_PROVIDER,
|
|
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)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_new_session_exposes_edit_approvals_as_modes_not_config_options(agent):
|
|
resp = await agent.new_session(cwd="/tmp")
|
|
|
|
assert resp.config_options is None
|
|
assert isinstance(resp.modes, SessionModeState)
|
|
assert resp.modes.current_mode_id == "default"
|
|
assert [(mode.id, mode.name) for mode in resp.modes.available_modes] == [
|
|
("default", "Default"),
|
|
("accept_edits", "Accept Edits"),
|
|
("dont_ask", "Don't Ask"),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_config_option_persists_edit_approval_policy_without_advertising_config(agent):
|
|
resp = await agent.new_session(cwd="/tmp")
|
|
update = await agent.set_config_option(
|
|
"edit_approval_policy",
|
|
resp.session_id,
|
|
"workspace_session",
|
|
)
|
|
state = agent.session_manager.get_session(resp.session_id)
|
|
|
|
assert isinstance(update, SetSessionConfigOptionResponse)
|
|
assert update.config_options == []
|
|
assert getattr(state, "mode", None) == "accept_edits"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# initialize
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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_advertises_provider_and_terminal_auth_methods(self, agent, monkeypatch):
|
|
monkeypatch.setattr("acp_adapter.auth.detect_provider", lambda: "openrouter")
|
|
monkeypatch.setattr("acp_adapter.server.detect_provider", lambda: "openrouter")
|
|
|
|
resp = await agent.initialize(protocol_version=1)
|
|
payloads = [method.model_dump(by_alias=True, exclude_none=True) for method in resp.auth_methods]
|
|
|
|
assert payloads[0]["id"] == "openrouter"
|
|
assert payloads[0]["name"] == "openrouter runtime credentials"
|
|
terminal = next(payload for payload in payloads if payload["id"] == TERMINAL_SETUP_AUTH_METHOD_ID)
|
|
assert terminal["type"] == "terminal"
|
|
assert terminal["args"] == ["--setup"]
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# authenticate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAuthenticate:
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_with_matching_method_id(self, agent, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"acp_adapter.server.detect_provider",
|
|
lambda: "openrouter",
|
|
)
|
|
resp = await agent.authenticate(method_id="openrouter")
|
|
assert isinstance(resp, AuthenticateResponse)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_is_case_insensitive(self, agent, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"acp_adapter.server.detect_provider",
|
|
lambda: "openrouter",
|
|
)
|
|
resp = await agent.authenticate(method_id="OpenRouter")
|
|
assert isinstance(resp, AuthenticateResponse)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_rejects_mismatched_method_id(self, agent, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"acp_adapter.server.detect_provider",
|
|
lambda: "openrouter",
|
|
)
|
|
resp = await agent.authenticate(method_id="totally-invalid-method")
|
|
assert resp is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_without_provider(self, agent, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"acp_adapter.server.detect_provider",
|
|
lambda: None,
|
|
)
|
|
resp = await agent.authenticate(method_id="openrouter")
|
|
assert resp is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authenticate_accepts_terminal_setup_after_provider_configured(self, agent, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"acp_adapter.server.detect_provider",
|
|
lambda: "openrouter",
|
|
)
|
|
resp = await agent.authenticate(method_id=TERMINAL_SETUP_AUTH_METHOD_ID)
|
|
assert isinstance(resp, AuthenticateResponse)
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# new_session / cancel / load / resume
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSessionOps:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_new_session_returns_authenticated_cross_provider_model_state(self):
|
|
manager = SessionManager(
|
|
agent_factory=lambda: SimpleNamespace(
|
|
model="gpt-5.4",
|
|
provider="openai-codex",
|
|
base_url="https://api.openai.com/v1",
|
|
)
|
|
)
|
|
acp_agent = HermesACPAgent(session_manager=manager)
|
|
picker_context = MagicMock()
|
|
picker_context.with_overrides.return_value = picker_context
|
|
payload = {
|
|
"providers": [
|
|
{
|
|
"slug": "anthropic",
|
|
"name": "Anthropic",
|
|
"models": ["claude-sonnet-4-6", "claude-sonnet-4-6"],
|
|
},
|
|
{
|
|
"slug": "openai-codex",
|
|
"name": "OpenAI Codex",
|
|
"models": [
|
|
{"id": "gpt-5.4"},
|
|
"gpt-5.4-mini",
|
|
],
|
|
},
|
|
],
|
|
}
|
|
|
|
with (
|
|
patch("hermes_cli.inventory.load_picker_context", return_value=picker_context),
|
|
patch("hermes_cli.inventory.build_models_payload", return_value=payload) as build_payload,
|
|
):
|
|
resp = await acp_agent.new_session(cwd="/tmp")
|
|
|
|
assert isinstance(resp.models, SessionModelState)
|
|
assert resp.models.current_model_id == "openai-codex:gpt-5.4"
|
|
assert [model.model_id for model in resp.models.available_models] == [
|
|
"anthropic:claude-sonnet-4-6",
|
|
"openai-codex:gpt-5.4",
|
|
"openai-codex:gpt-5.4-mini",
|
|
]
|
|
assert [model.name for model in resp.models.available_models] == [
|
|
"Anthropic · claude-sonnet-4-6",
|
|
"OpenAI Codex · gpt-5.4",
|
|
"OpenAI Codex · gpt-5.4-mini",
|
|
]
|
|
assert resp.models.available_models[1].description is not None
|
|
assert "current" in resp.models.available_models[1].description
|
|
picker_context.with_overrides.assert_called_once_with(
|
|
current_provider="openai-codex",
|
|
current_model="gpt-5.4",
|
|
current_base_url="https://api.openai.com/v1",
|
|
)
|
|
build_payload.assert_called_once_with(
|
|
picker_context,
|
|
explicit_only=True,
|
|
include_unconfigured=False,
|
|
picker_hints=False,
|
|
canonical_order=True,
|
|
pricing=False,
|
|
capabilities=False,
|
|
refresh=False,
|
|
probe_custom_providers=False,
|
|
probe_current_custom_provider=False,
|
|
max_models=ACP_MAX_MODELS_PER_PROVIDER,
|
|
)
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_available_commands_include_help(self, agent):
|
|
help_cmd = next(
|
|
(cmd for cmd in agent._available_commands() if cmd.name == "help"),
|
|
None,
|
|
)
|
|
|
|
assert help_cmd is not None
|
|
assert help_cmd.description == "List available commands"
|
|
assert help_cmd.input is None
|
|
|
|
|
|
def test_build_usage_update_for_zed_context_indicator(self, agent, mock_manager):
|
|
state = mock_manager.create_session(cwd="/tmp")
|
|
state.history = [{"role": "user", "content": "hello"}]
|
|
state.agent.context_compressor = MagicMock(context_length=100_000)
|
|
state.agent._cached_system_prompt = "system"
|
|
state.agent.tools = [{"type": "function", "function": {"name": "demo"}}]
|
|
|
|
with patch(
|
|
"agent.model_metadata.estimate_request_tokens_rough",
|
|
return_value=25_000,
|
|
):
|
|
update = agent._build_usage_update(state)
|
|
|
|
assert isinstance(update, UsageUpdate)
|
|
assert update.session_update == "usage_update"
|
|
assert update.size == 100_000
|
|
assert update.used == 25_000
|
|
|
|
|
|
|
|
|
|
@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_replays_persisted_history_to_client(self, agent):
|
|
mock_conn = MagicMock(spec=acp.Client)
|
|
mock_conn.session_update = AsyncMock()
|
|
agent._conn = mock_conn
|
|
|
|
new_resp = await agent.new_session(cwd="/tmp")
|
|
state = agent.session_manager.get_session(new_resp.session_id)
|
|
state.history = [{"role": "user", "content": "So tell me the current state"}]
|
|
|
|
mock_conn.session_update.reset_mock()
|
|
resp = await agent.resume_session(cwd="/tmp", session_id=new_resp.session_id)
|
|
await asyncio.sleep(0)
|
|
await asyncio.sleep(0)
|
|
|
|
assert isinstance(resp, ResumeSessionResponse)
|
|
updates = [call.kwargs["update"] for call in mock_conn.session_update.await_args_list]
|
|
assert any(
|
|
isinstance(update, UserMessageChunk)
|
|
and update.content.text == "So tell me the current state"
|
|
for update in updates
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# list / fork
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestListAndFork:
|
|
@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
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sessions_includes_title_and_updated_at(self, agent):
|
|
with patch.object(
|
|
agent.session_manager,
|
|
"list_sessions",
|
|
return_value=[
|
|
{
|
|
"session_id": "session-1",
|
|
"cwd": "/tmp/project",
|
|
"title": "Fix Zed session history",
|
|
"updated_at": 123.0,
|
|
}
|
|
],
|
|
):
|
|
resp = await agent.list_sessions(cwd="/tmp/project")
|
|
|
|
assert isinstance(resp.sessions[0], SessionInfo)
|
|
assert resp.sessions[0].title == "Fix Zed session history"
|
|
assert resp.sessions[0].updated_at == "123.0"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# session configuration / model routing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSessionConfiguration:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_router_accepts_stable_session_config_methods(self, agent):
|
|
new_resp = await agent.new_session(cwd="/tmp")
|
|
router = build_agent_router(agent)
|
|
|
|
mode_result = await router(
|
|
"session/set_mode",
|
|
{"modeId": "accept_edits", "sessionId": new_resp.session_id},
|
|
False,
|
|
)
|
|
config_result = await router(
|
|
"session/set_config_option",
|
|
{
|
|
"configId": "approval_mode",
|
|
"sessionId": new_resp.session_id,
|
|
"value": "auto",
|
|
},
|
|
False,
|
|
)
|
|
|
|
assert mode_result == {}
|
|
assert config_result["configOptions"] == []
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# prompt
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# on_connect
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slash commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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_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_compact_compresses_context(self, agent, mock_manager):
|
|
state = self._make_state(mock_manager)
|
|
state.history = [
|
|
{"role": "user", "content": "one"},
|
|
{"role": "assistant", "content": "two"},
|
|
{"role": "user", "content": "three"},
|
|
{"role": "assistant", "content": "four"},
|
|
]
|
|
state.agent.compression_enabled = True
|
|
state.agent._cached_system_prompt = "system"
|
|
state.agent.tools = None
|
|
original_session_db = object()
|
|
state.agent._session_db = original_session_db
|
|
|
|
def _compress_context(messages, system_prompt, *, approx_tokens, task_id, force):
|
|
assert state.agent._session_db is None
|
|
assert messages == state.history
|
|
assert system_prompt == "system"
|
|
assert approx_tokens == 40
|
|
assert task_id == state.session_id
|
|
assert force is True
|
|
return [{"role": "user", "content": "summary"}], "new-system"
|
|
|
|
state.agent._compress_context = MagicMock(side_effect=_compress_context)
|
|
|
|
with (
|
|
patch.object(agent.session_manager, "save_session") as mock_save,
|
|
patch(
|
|
"agent.model_metadata.estimate_request_tokens_rough",
|
|
side_effect=[40, 12],
|
|
),
|
|
):
|
|
result = agent._handle_slash_command("/compress", state)
|
|
|
|
assert "Context compressed: 4 -> 1 messages" in result
|
|
assert "~40 -> ~12 tokens" in result
|
|
assert state.history == [{"role": "user", "content": "summary"}]
|
|
assert state.agent._session_db is original_session_db
|
|
state.agent._compress_context.assert_called_once_with(
|
|
[
|
|
{"role": "user", "content": "one"},
|
|
{"role": "assistant", "content": "two"},
|
|
{"role": "user", "content": "three"},
|
|
{"role": "assistant", "content": "four"},
|
|
],
|
|
"system",
|
|
approx_tokens=40,
|
|
task_id=state.session_id,
|
|
force=True,
|
|
)
|
|
mock_save.assert_called_once_with(state.session_id)
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_slash_handler_cwd_pin_does_not_leak(self, agent, mock_manager, tmp_path):
|
|
"""The pin is scoped to the handler's own context copy.
|
|
|
|
Concurrent ACP sessions share the event loop, so a handler that pinned
|
|
the ambient context would leave its workspace bound for whatever runs
|
|
next. Asserting the ambient value is unchanged after dispatch keeps the
|
|
fix from trading one cross-session leak for another.
|
|
"""
|
|
from agent.runtime_cwd import resolve_agent_cwd
|
|
|
|
workspace = tmp_path / "project"
|
|
workspace.mkdir()
|
|
state = mock_manager.create_session(cwd=str(workspace))
|
|
state.cwd = str(workspace)
|
|
state.agent.model = "test-model"
|
|
state.agent.provider = "openrouter"
|
|
|
|
before = str(resolve_agent_cwd())
|
|
agent._handle_slash_command("/help", state)
|
|
assert str(resolve_agent_cwd()) == before
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _register_session_mcp_servers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRegisterSessionMcpServers:
|
|
"""Tests for ACP MCP server registration in session lifecycle."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_noop_when_no_servers(self, agent, mock_manager):
|
|
"""No-op when mcp_servers is None or empty."""
|
|
state = mock_manager.create_session(cwd="/tmp")
|
|
# Should not raise
|
|
await agent._register_session_mcp_servers(state, None)
|
|
await agent._register_session_mcp_servers(state, [])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_registers_stdio_servers(self, agent, mock_manager):
|
|
"""McpServerStdio servers are converted and passed to register_mcp_servers."""
|
|
from acp.schema import McpServerStdio, EnvVariable
|
|
|
|
state = mock_manager.create_session(cwd="/tmp")
|
|
# Give the mock agent the attributes _register_session_mcp_servers reads
|
|
state.agent.enabled_toolsets = ["hermes-acp"]
|
|
state.agent.disabled_toolsets = None
|
|
state.agent.tools = []
|
|
state.agent.valid_tool_names = set()
|
|
|
|
server = McpServerStdio(
|
|
name="test-server",
|
|
command="/usr/bin/test",
|
|
args=["--flag"],
|
|
env=[EnvVariable(name="KEY", value="val")],
|
|
)
|
|
|
|
registered_config = {}
|
|
def capture_register(config_map):
|
|
registered_config.update(config_map)
|
|
return ["mcp_test_server_tool1"]
|
|
|
|
with patch("tools.mcp_tool.register_mcp_servers", side_effect=capture_register), \
|
|
patch("model_tools.get_tool_definitions", return_value=[]):
|
|
await agent._register_session_mcp_servers(state, [server])
|
|
|
|
assert "test-server" in registered_config
|
|
cfg = registered_config["test-server"]
|
|
assert cfg["command"] == "/usr/bin/test"
|
|
assert cfg["args"] == ["--flag"]
|
|
assert cfg["env"] == {"KEY": "val"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refreshes_agent_tool_surface(self, agent, mock_manager):
|
|
"""After MCP registration, agent.tools and valid_tool_names are refreshed."""
|
|
from acp.schema import McpServerStdio
|
|
|
|
state = mock_manager.create_session(cwd="/tmp")
|
|
state.agent.enabled_toolsets = ["hermes-acp"]
|
|
state.agent.disabled_toolsets = None
|
|
state.agent.tools = []
|
|
state.agent.valid_tool_names = set()
|
|
state.agent._cached_system_prompt = "old prompt"
|
|
state.agent._memory_manager = SimpleNamespace(
|
|
get_all_tool_schemas=lambda: [
|
|
{"name": "hindsight_recall", "description": "Recall", "parameters": {}}
|
|
]
|
|
)
|
|
|
|
server = McpServerStdio(
|
|
name="srv",
|
|
command="/bin/test",
|
|
args=[],
|
|
env=[],
|
|
)
|
|
|
|
fake_tools = [
|
|
{"function": {"name": "mcp_srv_search"}},
|
|
{"function": {"name": "memory"}},
|
|
{"function": {"name": "terminal"}},
|
|
]
|
|
|
|
with patch("tools.mcp_tool.register_mcp_servers", return_value=["mcp_srv_search"]), \
|
|
patch("model_tools.get_tool_definitions", return_value=fake_tools) as mock_defs:
|
|
await agent._register_session_mcp_servers(state, [server])
|
|
|
|
mock_defs.assert_called_once_with(
|
|
enabled_toolsets=["hermes-acp", "mcp-srv"],
|
|
disabled_toolsets=None,
|
|
quiet_mode=True,
|
|
)
|
|
assert state.agent.enabled_toolsets == ["hermes-acp", "mcp-srv"]
|
|
assert state.agent.tools is fake_tools
|
|
assert state.agent.tools[-1] == {
|
|
"type": "function",
|
|
"function": {
|
|
"name": "hindsight_recall",
|
|
"description": "Recall",
|
|
"parameters": {},
|
|
},
|
|
}
|
|
assert state.agent.valid_tool_names == {
|
|
"hindsight_recall",
|
|
"memory",
|
|
"mcp_srv_search",
|
|
"terminal",
|
|
}
|
|
# _invalidate_system_prompt should have been called
|
|
state.agent._invalidate_system_prompt.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_failure_logs_warning(self, agent, mock_manager):
|
|
"""If register_mcp_servers raises, warning is logged but no crash."""
|
|
from acp.schema import McpServerStdio
|
|
|
|
state = mock_manager.create_session(cwd="/tmp")
|
|
server = McpServerStdio(
|
|
name="bad",
|
|
command="/nonexistent",
|
|
args=[],
|
|
env=[],
|
|
)
|
|
|
|
with patch("tools.mcp_tool.register_mcp_servers", side_effect=RuntimeError("boom")):
|
|
# Should not raise
|
|
await agent._register_session_mcp_servers(state, [server])
|