Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
"""Shared fixtures for tests/acp.
|
||||
|
||||
Keeps the ACP server tests offline: ``HermesACPAgent._build_model_state``
|
||||
calls ``hermes_cli.inventory.build_models_payload``, which (without this
|
||||
fixture) performs live network fetches — models.dev registry, GitHub model
|
||||
catalog, Copilot token exchange, Anthropic model list — adding ~3s of real
|
||||
SSL/socket time to every test that creates or loads a session (~147s total
|
||||
for test_server.py alone).
|
||||
|
||||
Tests that assert model-state behavior re-patch these same attributes with
|
||||
``unittest.mock.patch`` / ``monkeypatch``; inner patches win, so this
|
||||
default is transparent to them.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _offline_model_inventory(monkeypatch):
|
||||
"""Stub the shared model inventory so ACP tests never hit the network."""
|
||||
import hermes_cli.inventory as inventory
|
||||
|
||||
class _StubPickerContext:
|
||||
def with_overrides(self, **_kwargs):
|
||||
return self
|
||||
|
||||
monkeypatch.setattr(inventory, "load_picker_context", lambda: _StubPickerContext())
|
||||
monkeypatch.setattr(
|
||||
inventory,
|
||||
"build_models_payload",
|
||||
lambda *_args, **_kwargs: {"providers": []},
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Tests for GHSA-96vc-wcxf-jjff and GHSA-qg5c-hvr5-hjgr.
|
||||
|
||||
Two related ACP approval-flow issues:
|
||||
- 96vc: ACP didn't set HERMES_EXEC_ASK, so `check_all_command_guards`
|
||||
took the non-interactive auto-approve path and never consulted the
|
||||
ACP-supplied callback.
|
||||
- qg5c: `_approval_callback` was a module-global in terminal_tool;
|
||||
overlapping ACP sessions overwrote each other's callback slot.
|
||||
|
||||
Both fixed together by:
|
||||
1. Setting HERMES_EXEC_ASK inside _run_agent (wraps the agent call).
|
||||
2. Storing the callback in thread-local state so concurrent executor
|
||||
threads don't collide.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_approval_state(monkeypatch):
|
||||
"""Keep these security regression tests hermetic.
|
||||
|
||||
Earlier tests (e.g. tests/acp/test_permissions.py) lazily load the
|
||||
developer's real ``~/.hermes/config.yaml`` command allowlist into
|
||||
``tools.approval._permanent_approved``. If that allowlist contains a
|
||||
pattern like "recursive delete", ``rm -rf …`` is auto-approved before
|
||||
the interactive callback fires and the GHSA regression assertions fail
|
||||
for reasons unrelated to the code under test.
|
||||
"""
|
||||
import tools.approval as _approval
|
||||
|
||||
monkeypatch.setattr(_approval, "_permanent_approved", set())
|
||||
monkeypatch.setattr(_approval, "_session_approved", {})
|
||||
# These tests assert the *manual* interactive-callback path. The default
|
||||
# config is approvals.mode=smart, whose guardian LLM can auto-approve the
|
||||
# command before the callback is consulted (test-order dependent, since
|
||||
# load_config() caching decides which config file is in effect). Pin the
|
||||
# mode so the GHSA regression path is what actually runs.
|
||||
monkeypatch.setattr(_approval, "_get_approval_mode", lambda: "manual")
|
||||
|
||||
|
||||
class TestThreadLocalApprovalCallback:
|
||||
"""GHSA-qg5c-hvr5-hjgr: set_approval_callback must be per-thread so
|
||||
concurrent ACP sessions don't stomp on each other's handlers."""
|
||||
|
||||
def test_set_and_get_in_same_thread(self):
|
||||
from tools.terminal_tool import (
|
||||
set_approval_callback,
|
||||
_get_approval_callback,
|
||||
)
|
||||
|
||||
cb1 = lambda cmd, desc: "once" # noqa: E731
|
||||
set_approval_callback(cb1)
|
||||
assert _get_approval_callback() is cb1
|
||||
|
||||
def test_callback_not_visible_in_different_thread(self):
|
||||
"""Thread A's callback is NOT visible to Thread B."""
|
||||
from tools.terminal_tool import (
|
||||
set_approval_callback,
|
||||
_get_approval_callback,
|
||||
)
|
||||
|
||||
cb_a = lambda cmd, desc: "thread_a" # noqa: E731
|
||||
cb_b = lambda cmd, desc: "thread_b" # noqa: E731
|
||||
|
||||
seen_in_a = []
|
||||
seen_in_b = []
|
||||
|
||||
def thread_a():
|
||||
set_approval_callback(cb_a)
|
||||
# Pause so thread B has time to set its own callback
|
||||
import time
|
||||
time.sleep(0.05)
|
||||
seen_in_a.append(_get_approval_callback())
|
||||
|
||||
def thread_b():
|
||||
set_approval_callback(cb_b)
|
||||
import time
|
||||
time.sleep(0.05)
|
||||
seen_in_b.append(_get_approval_callback())
|
||||
|
||||
ta = threading.Thread(target=thread_a)
|
||||
tb = threading.Thread(target=thread_b)
|
||||
ta.start()
|
||||
tb.start()
|
||||
ta.join()
|
||||
tb.join()
|
||||
|
||||
# Each thread must see ONLY its own callback — not the other's
|
||||
assert seen_in_a == [cb_a]
|
||||
assert seen_in_b == [cb_b]
|
||||
|
||||
def test_main_thread_callback_not_leaked_to_worker(self):
|
||||
"""A callback set in the main thread does NOT leak into a
|
||||
freshly-spawned worker thread."""
|
||||
from tools.terminal_tool import (
|
||||
set_approval_callback,
|
||||
_get_approval_callback,
|
||||
)
|
||||
|
||||
cb_main = lambda cmd, desc: "main" # noqa: E731
|
||||
set_approval_callback(cb_main)
|
||||
|
||||
worker_saw = []
|
||||
|
||||
def worker():
|
||||
worker_saw.append(_get_approval_callback())
|
||||
|
||||
t = threading.Thread(target=worker)
|
||||
t.start()
|
||||
t.join()
|
||||
|
||||
# Worker thread has no callback set — TLS is empty for it
|
||||
assert worker_saw == [None]
|
||||
# Main thread still has its callback
|
||||
assert _get_approval_callback() is cb_main
|
||||
|
||||
|
||||
def test_sudo_password_cache_does_not_leak_across_threads(self):
|
||||
"""Interactive sudo cache must not bleed into another executor thread."""
|
||||
from tools.terminal_tool import (
|
||||
_get_cached_sudo_password,
|
||||
_reset_cached_sudo_passwords,
|
||||
_set_cached_sudo_password,
|
||||
)
|
||||
|
||||
_reset_cached_sudo_passwords()
|
||||
_set_cached_sudo_password("main-thread-password")
|
||||
|
||||
worker_saw = []
|
||||
|
||||
def worker():
|
||||
worker_saw.append(_get_cached_sudo_password())
|
||||
|
||||
t = threading.Thread(target=worker)
|
||||
t.start()
|
||||
t.join()
|
||||
|
||||
assert worker_saw == [""]
|
||||
assert _get_cached_sudo_password() == "main-thread-password"
|
||||
|
||||
|
||||
|
||||
class TestAcpExecAskGate:
|
||||
"""GHSA-96vc-wcxf-jjff: ACP's _run_agent must set HERMES_INTERACTIVE so
|
||||
that tools.approval.check_all_command_guards takes the CLI-interactive
|
||||
path (consults the registered callback via prompt_dangerous_approval)
|
||||
instead of the non-interactive auto-approve shortcut.
|
||||
|
||||
(HERMES_EXEC_ASK takes the gateway-queue path which requires a
|
||||
notify_cb registered in _gateway_notify_cbs — not applicable to ACP,
|
||||
which uses a direct callback shape.)"""
|
||||
|
||||
def test_interactive_env_var_routes_to_callback(self, monkeypatch):
|
||||
"""When HERMES_INTERACTIVE is set and an approval callback is
|
||||
registered, a dangerous command must route through the callback."""
|
||||
# Clean env
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
||||
|
||||
from tools.approval import check_all_command_guards
|
||||
|
||||
called_with = []
|
||||
|
||||
def fake_cb(command, description, *, allow_permanent=True):
|
||||
called_with.append((command, description))
|
||||
return "once"
|
||||
|
||||
# Without HERMES_INTERACTIVE: takes auto-approve path, callback NOT called
|
||||
result = check_all_command_guards(
|
||||
"rm -rf /tmp/test-exec-ask", "local", approval_callback=fake_cb,
|
||||
)
|
||||
assert result["approved"] is True
|
||||
assert called_with == [], (
|
||||
"without HERMES_INTERACTIVE the non-interactive auto-approve "
|
||||
"path should fire without consulting the callback"
|
||||
)
|
||||
|
||||
# With HERMES_INTERACTIVE: callback IS called, approval flows through it
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
called_with.clear()
|
||||
result = check_all_command_guards(
|
||||
"rm -rf /tmp/test-exec-ask", "local", approval_callback=fake_cb,
|
||||
)
|
||||
assert called_with, (
|
||||
"with HERMES_INTERACTIVE the approval path should consult the "
|
||||
"registered callback — this was the ACP bypass in "
|
||||
"GHSA-96vc-wcxf-jjff"
|
||||
)
|
||||
assert result["approved"] is True
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Tests for acp_adapter.auth — provider detection."""
|
||||
|
||||
from acp_adapter.auth import (
|
||||
TERMINAL_SETUP_AUTH_METHOD_ID,
|
||||
build_auth_methods,
|
||||
has_provider,
|
||||
detect_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestHasProvider:
|
||||
def test_has_provider_with_resolved_runtime(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda: {"provider": "openrouter", "api_key": "sk-or-test"},
|
||||
)
|
||||
assert has_provider() is True
|
||||
|
||||
|
||||
|
||||
|
||||
class TestDetectProvider:
|
||||
def test_detect_openrouter(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda: {"provider": "openrouter", "api_key": "sk-or-test"},
|
||||
)
|
||||
assert detect_provider() == "openrouter"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TestBuildAuthMethods:
|
||||
def test_build_auth_methods_returns_provider_and_terminal_when_configured(self, monkeypatch):
|
||||
monkeypatch.setattr("acp_adapter.auth.detect_provider", lambda: "openrouter")
|
||||
|
||||
methods = build_auth_methods()
|
||||
payloads = [method.model_dump(by_alias=True, exclude_none=True) for method in methods]
|
||||
|
||||
assert payloads[0]["id"] == "openrouter"
|
||||
assert payloads[0]["name"] == "openrouter runtime credentials"
|
||||
assert any(payload["id"] == TERMINAL_SETUP_AUTH_METHOD_ID for payload in payloads)
|
||||
terminal = next(payload for payload in payloads if payload["id"] == TERMINAL_SETUP_AUTH_METHOD_ID)
|
||||
assert terminal["type"] == "terminal"
|
||||
assert terminal["args"] == ["--setup"]
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Tests for ACP pre-edit approval gating."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from acp_adapter.edit_approval import (
|
||||
EditProposal,
|
||||
build_acp_edit_tool_call,
|
||||
clear_edit_approval_requester,
|
||||
set_edit_approval_requester,
|
||||
should_auto_approve_edit,
|
||||
)
|
||||
from model_tools import handle_function_call
|
||||
|
||||
|
||||
def teardown_function() -> None:
|
||||
clear_edit_approval_requester()
|
||||
|
||||
|
||||
def test_acp_permission_tool_call_uses_edit_kind_and_diff_content():
|
||||
proposal = EditProposal(
|
||||
tool_name="write_file",
|
||||
path="demo.txt",
|
||||
old_text="old\n",
|
||||
new_text="new\n",
|
||||
arguments={"path": "demo.txt", "content": "new\n"},
|
||||
)
|
||||
|
||||
tool_call = build_acp_edit_tool_call(proposal)
|
||||
|
||||
assert tool_call.kind == "edit"
|
||||
assert tool_call.status == "pending"
|
||||
assert tool_call.rawInput == {"tool": "write_file", "arguments": proposal.arguments}
|
||||
assert len(tool_call.content) == 1
|
||||
diff = tool_call.content[0]
|
||||
assert diff.path == "demo.txt"
|
||||
assert diff.oldText == "old\n"
|
||||
assert diff.newText == "new\n"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_requester_exception_denies_and_does_not_mutate(tmp_path):
|
||||
target = tmp_path / "sample.txt"
|
||||
target.write_text("before\n", encoding="utf-8")
|
||||
|
||||
def boom(_proposal):
|
||||
raise RuntimeError("zed disconnected")
|
||||
|
||||
set_edit_approval_requester(boom)
|
||||
|
||||
result = json.loads(
|
||||
handle_function_call(
|
||||
"write_file",
|
||||
{"path": str(target), "content": "after\n"},
|
||||
task_id="acp-edit-exception",
|
||||
)
|
||||
)
|
||||
|
||||
assert "error" in result
|
||||
assert "Edit approval denied" in result["error"]
|
||||
assert target.read_text(encoding="utf-8") == "before\n"
|
||||
|
||||
|
||||
def test_patch_replace_rejection_does_not_mutate(tmp_path):
|
||||
target = tmp_path / "sample.txt"
|
||||
target.write_text("alpha\nbeta\n", encoding="utf-8")
|
||||
|
||||
set_edit_approval_requester(lambda _proposal: False)
|
||||
|
||||
result = json.loads(
|
||||
handle_function_call(
|
||||
"patch",
|
||||
{
|
||||
"mode": "replace",
|
||||
"path": str(target),
|
||||
"old_string": "beta\n",
|
||||
"new_string": "gamma\n",
|
||||
},
|
||||
task_id="acp-patch-reject",
|
||||
)
|
||||
)
|
||||
|
||||
assert "error" in result
|
||||
assert "Edit approval denied" in result["error"]
|
||||
assert target.read_text(encoding="utf-8") == "alpha\nbeta\n"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_workspace_auto_approval_allows_workspace_and_tmp_but_not_sensitive(tmp_path):
|
||||
workspace_file = tmp_path / "src.py"
|
||||
# Use tempfile.gettempdir() so this test exercises the same code path on
|
||||
# Linux (`/tmp`), macOS (`/private/var/folders/...`) and Windows
|
||||
# (`%LOCALAPPDATA%\Temp`). Before the fix this branch only worked on Linux.
|
||||
tmp_file = Path(tempfile.gettempdir()) / "hermes-acp-auto-approve-test.txt"
|
||||
env_file = tmp_path / ".env"
|
||||
|
||||
assert should_auto_approve_edit(
|
||||
EditProposal("write_file", str(workspace_file), None, "x", {}),
|
||||
"workspace_session",
|
||||
str(tmp_path),
|
||||
)
|
||||
assert should_auto_approve_edit(
|
||||
EditProposal("write_file", str(tmp_file), None, "x", {}),
|
||||
"workspace_session",
|
||||
str(tmp_path),
|
||||
)
|
||||
assert not should_auto_approve_edit(
|
||||
EditProposal("write_file", str(env_file), None, "SECRET=x", {}),
|
||||
"session",
|
||||
str(tmp_path),
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests for acp_adapter.entry startup wiring."""
|
||||
|
||||
import sys
|
||||
|
||||
import acp
|
||||
import pytest
|
||||
|
||||
from acp_adapter import entry
|
||||
|
||||
|
||||
def test_main_enables_unstable_protocol(monkeypatch):
|
||||
calls = {}
|
||||
|
||||
async def fake_run_agent(agent, **kwargs):
|
||||
calls["kwargs"] = kwargs
|
||||
|
||||
monkeypatch.setattr(entry, "_setup_logging", lambda: None)
|
||||
monkeypatch.setattr(entry, "_load_env", lambda: None)
|
||||
monkeypatch.setattr(acp, "run_agent", fake_run_agent)
|
||||
|
||||
entry.main([])
|
||||
|
||||
assert calls["kwargs"]["use_unstable_protocol"] is True
|
||||
|
||||
|
||||
def test_main_skips_configured_mcp_discovery_when_requested(monkeypatch):
|
||||
discovery_calls = []
|
||||
|
||||
async def fake_run_agent(agent, **kwargs):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(entry, "_setup_logging", lambda: None)
|
||||
monkeypatch.setattr(entry, "_load_env", lambda: None)
|
||||
monkeypatch.setenv("HERMES_ACP_SKIP_CONFIGURED_MCP", "1")
|
||||
monkeypatch.setattr(
|
||||
"tools.mcp_tool.discover_mcp_tools",
|
||||
lambda: discovery_calls.append(True),
|
||||
)
|
||||
monkeypatch.setattr(acp, "run_agent", fake_run_agent)
|
||||
|
||||
entry.main([])
|
||||
|
||||
assert discovery_calls == []
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_main_setup_offers_browser_install_when_tty(monkeypatch):
|
||||
"""When stdin is a TTY and the user answers yes, model setup is followed
|
||||
by a browser-tools bootstrap call."""
|
||||
monkeypatch.setattr("hermes_cli.main.main", lambda: None)
|
||||
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
|
||||
monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "y")
|
||||
|
||||
bootstrap_calls = []
|
||||
monkeypatch.setattr(
|
||||
entry,
|
||||
"_run_setup_browser",
|
||||
lambda assume_yes=False: bootstrap_calls.append(assume_yes) or 0,
|
||||
)
|
||||
|
||||
entry.main(["--setup"])
|
||||
|
||||
assert bootstrap_calls == [False]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_main_setup_browser_propagates_browser_failure(monkeypatch):
|
||||
"""If browser install fails, exit code is 1."""
|
||||
def fake_ensure(dep, interactive=True):
|
||||
return dep != "browser" # browser fails
|
||||
|
||||
monkeypatch.setattr("hermes_cli.dep_ensure.ensure_dependency", fake_ensure)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
entry.main(["--setup-browser"])
|
||||
assert excinfo.value.code == 1
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Tests for acp_adapter.events — callback factories for ACP notifications."""
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import warnings
|
||||
from concurrent.futures import Future
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import acp
|
||||
from acp.schema import AgentPlanUpdate
|
||||
|
||||
from acp_adapter.events import (
|
||||
_build_plan_update_from_todo_result,
|
||||
_send_update,
|
||||
make_message_cb,
|
||||
make_step_cb,
|
||||
make_thinking_cb,
|
||||
make_tool_progress_cb,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_conn():
|
||||
"""Mock ACP Client connection."""
|
||||
conn = MagicMock(spec=acp.Client)
|
||||
conn.session_update = AsyncMock()
|
||||
return conn
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def event_loop_fixture():
|
||||
"""Create a real event loop for testing threadsafe coroutine submission."""
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool progress callback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToolProgressCallback:
|
||||
def test_emits_tool_call_start(self, mock_conn, event_loop_fixture):
|
||||
"""Tool progress should emit a ToolCallStart update."""
|
||||
tool_call_ids = {}
|
||||
tool_call_meta = {}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids, tool_call_meta)
|
||||
|
||||
# Run callback in the event loop context
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.return_value = None
|
||||
mock_rcts.return_value = future
|
||||
|
||||
cb("tool.started", "terminal", "$ ls -la", {"command": "ls -la"})
|
||||
|
||||
# Should have tracked the tool call ID
|
||||
assert "terminal" in tool_call_ids
|
||||
|
||||
# Should have called run_coroutine_threadsafe
|
||||
mock_rcts.assert_called_once()
|
||||
coro = mock_rcts.call_args[0][0]
|
||||
# The coroutine should be conn.session_update
|
||||
assert mock_conn.session_update.called or coro is not None
|
||||
|
||||
|
||||
|
||||
def test_duplicate_same_name_tool_calls_use_fifo_ids(self, mock_conn, event_loop_fixture):
|
||||
"""Multiple same-name tool calls should be tracked independently in order."""
|
||||
tool_call_ids = {}
|
||||
tool_call_meta = {}
|
||||
loop = event_loop_fixture
|
||||
|
||||
progress_cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids, tool_call_meta)
|
||||
step_cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, tool_call_meta)
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.return_value = None
|
||||
mock_rcts.return_value = future
|
||||
|
||||
progress_cb("tool.started", "terminal", "$ ls", {"command": "ls"})
|
||||
progress_cb("tool.started", "terminal", "$ pwd", {"command": "pwd"})
|
||||
assert len(tool_call_ids["terminal"]) == 2
|
||||
|
||||
step_cb(1, [{"name": "terminal", "result": "ok-1"}])
|
||||
assert len(tool_call_ids["terminal"]) == 1
|
||||
|
||||
step_cb(2, [{"name": "terminal", "result": "ok-2"}])
|
||||
assert "terminal" not in tool_call_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thinking callback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step callback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStepCallback:
|
||||
def test_completes_tracked_tool_calls(self, mock_conn, event_loop_fixture):
|
||||
"""Step callback should mark tracked tools as completed."""
|
||||
tool_call_ids = {"terminal": "tc-abc123"}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, {})
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.return_value = None
|
||||
mock_rcts.return_value = future
|
||||
|
||||
cb(1, [{"name": "terminal", "result": "success"}])
|
||||
|
||||
# Tool should have been removed from tracking
|
||||
assert "terminal" not in tool_call_ids
|
||||
mock_rcts.assert_called_once()
|
||||
|
||||
|
||||
|
||||
def test_result_passed_to_build_tool_complete(self, mock_conn, event_loop_fixture):
|
||||
"""Tool result from prev_tools dict is forwarded to build_tool_complete."""
|
||||
from collections import deque
|
||||
|
||||
tool_call_ids = {"terminal": deque(["tc-xyz789"])}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, {})
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts, \
|
||||
patch("acp_adapter.events.build_tool_complete") as mock_btc:
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.return_value = None
|
||||
mock_rcts.return_value = future
|
||||
|
||||
# Provide a result string in the tool info dict
|
||||
cb(1, [{"name": "terminal", "result": '{"output": "hello"}'}])
|
||||
|
||||
mock_btc.assert_called_once_with(
|
||||
"tc-xyz789", "terminal", result='{"output": "hello"}', function_args=None, snapshot=None
|
||||
)
|
||||
|
||||
|
||||
|
||||
def test_tool_progress_captures_snapshot_metadata(self, mock_conn, event_loop_fixture):
|
||||
tool_call_ids = {}
|
||||
tool_call_meta = {}
|
||||
loop = event_loop_fixture
|
||||
|
||||
with patch("acp_adapter.events.make_tool_call_id", return_value="tc-meta"), \
|
||||
patch("acp_adapter.events._send_update") as mock_send, \
|
||||
patch("agent.display.capture_local_edit_snapshot", return_value="snapshot"):
|
||||
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids, tool_call_meta)
|
||||
cb("tool.started", "write_file", None, {"path": "diff-test.txt", "content": "hello"})
|
||||
|
||||
assert list(tool_call_ids["write_file"]) == ["tc-meta"]
|
||||
assert tool_call_meta["tc-meta"] == {
|
||||
"args": {"path": "diff-test.txt", "content": "hello"},
|
||||
"snapshot": "snapshot",
|
||||
}
|
||||
mock_send.assert_called_once()
|
||||
|
||||
def test_todo_completion_emits_native_plan_update_after_tool_completion(self, mock_conn, event_loop_fixture):
|
||||
from collections import deque
|
||||
|
||||
tool_call_ids = {"todo": deque(["tc-todo"])}
|
||||
loop = event_loop_fixture
|
||||
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, {})
|
||||
todo_result = (
|
||||
'{"todos":['
|
||||
'{"id":"inspect","content":"Inspect ACP","status":"completed"},'
|
||||
'{"id":"patch","content":"Patch renderer","status":"in_progress"},'
|
||||
'{"id":"old","content":"Drop stale task","status":"cancelled"}'
|
||||
'],"summary":{"total":3}}'
|
||||
)
|
||||
|
||||
with patch("acp_adapter.events._send_update") as mock_send:
|
||||
cb(1, [{"name": "todo", "result": todo_result}])
|
||||
|
||||
updates = [call.args[3] for call in mock_send.call_args_list]
|
||||
assert [getattr(update, "session_update", None) for update in updates] == [
|
||||
"tool_call_update",
|
||||
"plan",
|
||||
]
|
||||
plan = updates[1]
|
||||
assert isinstance(plan, AgentPlanUpdate)
|
||||
assert [entry.content for entry in plan.entries] == [
|
||||
"Inspect ACP",
|
||||
"Patch renderer",
|
||||
"[cancelled] Drop stale task",
|
||||
]
|
||||
assert [entry.status for entry in plan.entries] == ["completed", "in_progress", "completed"]
|
||||
assert [entry.priority for entry in plan.entries] == ["medium", "medium", "medium"]
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message callback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scheduler-failure regression
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSendUpdate:
|
||||
def test_scheduler_failure_closes_update_coroutine(self, event_loop_fixture):
|
||||
"""If run_coroutine_threadsafe raises, _send_update must close the coro."""
|
||||
created = {"coro": None}
|
||||
|
||||
async def _session_update(session_id, update):
|
||||
return None
|
||||
|
||||
conn = MagicMock()
|
||||
|
||||
def _capture_update(session_id, update):
|
||||
created["coro"] = _session_update(session_id, update)
|
||||
return created["coro"]
|
||||
|
||||
conn.session_update = _capture_update
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
with patch(
|
||||
"agent.async_utils.asyncio.run_coroutine_threadsafe",
|
||||
side_effect=RuntimeError("scheduler down"),
|
||||
):
|
||||
_send_update(conn, "session-1", event_loop_fixture, {"type": "noop"})
|
||||
gc.collect()
|
||||
|
||||
assert created["coro"] is not None
|
||||
assert created["coro"].cr_frame is None
|
||||
# Only count warnings about THIS test's coroutine; other tests
|
||||
# may emit unrelated
|
||||
# "coroutine was never awaited" warnings that bleed through.
|
||||
runtime_warnings = [
|
||||
w for w in caught
|
||||
if issubclass(w.category, RuntimeWarning)
|
||||
and "was never awaited" in str(w.message)
|
||||
and "_session_update" in str(w.message)
|
||||
]
|
||||
assert runtime_warnings == []
|
||||
@@ -0,0 +1,313 @@
|
||||
"""End-to-end tests for ACP MCP server registration and tool-result reporting.
|
||||
|
||||
Exercises the full flow through the ACP server layer:
|
||||
new_session(mcpServers) → MCP tools registered → prompt() →
|
||||
tool_progress_callback (ToolCallStart) →
|
||||
step_callback with results (ToolCallUpdate with rawOutput) →
|
||||
session_update events arrive at the mock client
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import acp
|
||||
from acp.schema import (
|
||||
EnvVariable,
|
||||
HttpHeader,
|
||||
McpServerHttp,
|
||||
McpServerStdio,
|
||||
NewSessionResponse,
|
||||
PromptResponse,
|
||||
TextContentBlock,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
)
|
||||
|
||||
from acp_adapter.server import HermesACPAgent
|
||||
from acp_adapter.session import SessionManager
|
||||
from acp_adapter.tools import build_tool_start
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_manager():
|
||||
return SessionManager(agent_factory=lambda: MagicMock(name="MockAIAgent"))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def acp_agent(mock_manager):
|
||||
return HermesACPAgent(session_manager=mock_manager)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E2E: MCP registration → prompt → tool events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMcpRegistrationE2E:
|
||||
"""Full flow: session with MCP servers → prompt with tool calls → ACP events."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_with_mcp_servers_registers_tools(self, acp_agent, mock_manager):
|
||||
"""new_session with mcpServers converts them to Hermes config and registers."""
|
||||
servers = [
|
||||
McpServerStdio(
|
||||
name="test-fs",
|
||||
command="/usr/bin/mcp-fs",
|
||||
args=["--root", "/tmp"],
|
||||
env=[EnvVariable(name="DEBUG", value="1")],
|
||||
),
|
||||
McpServerHttp(
|
||||
name="test-api",
|
||||
url="https://api.example.com/mcp",
|
||||
headers=[HttpHeader(name="Authorization", value="Bearer tok123")],
|
||||
),
|
||||
]
|
||||
|
||||
registered_configs = {}
|
||||
|
||||
def mock_register(config_map):
|
||||
registered_configs.update(config_map)
|
||||
return ["mcp_test_fs_read", "mcp_test_fs_write", "mcp_test_api_search"]
|
||||
|
||||
fake_tools = [
|
||||
{"function": {"name": "mcp_test_fs_read"}},
|
||||
{"function": {"name": "mcp_test_fs_write"}},
|
||||
{"function": {"name": "mcp_test_api_search"}},
|
||||
{"function": {"name": "terminal"}},
|
||||
]
|
||||
|
||||
with patch("tools.mcp_tool.register_mcp_servers", side_effect=mock_register), \
|
||||
patch("model_tools.get_tool_definitions", return_value=fake_tools):
|
||||
resp = await acp_agent.new_session(cwd="/tmp", mcp_servers=servers)
|
||||
|
||||
assert isinstance(resp, NewSessionResponse)
|
||||
state = mock_manager.get_session(resp.session_id)
|
||||
|
||||
# Verify stdio server was converted correctly
|
||||
assert "test-fs" in registered_configs
|
||||
fs_cfg = registered_configs["test-fs"]
|
||||
assert fs_cfg["command"] == "/usr/bin/mcp-fs"
|
||||
assert fs_cfg["args"] == ["--root", "/tmp"]
|
||||
assert fs_cfg["env"] == {"DEBUG": "1"}
|
||||
|
||||
# Verify HTTP server was converted correctly
|
||||
assert "test-api" in registered_configs
|
||||
api_cfg = registered_configs["test-api"]
|
||||
assert api_cfg["url"] == "https://api.example.com/mcp"
|
||||
assert api_cfg["headers"] == {"Authorization": "Bearer tok123"}
|
||||
|
||||
# Verify agent tool surface was refreshed
|
||||
assert state.agent.tools == fake_tools
|
||||
assert state.agent.valid_tool_names == {
|
||||
"mcp_test_fs_read", "mcp_test_fs_write", "mcp_test_api_search", "terminal"
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_with_tool_calls_emits_acp_events(self, acp_agent, mock_manager):
|
||||
"""Prompt → agent fires callbacks → ACP ToolCallStart + ToolCallUpdate events."""
|
||||
resp = await acp_agent.new_session(cwd="/tmp")
|
||||
session_id = resp.session_id
|
||||
state = mock_manager.get_session(session_id)
|
||||
|
||||
# Wire up a mock ACP client connection
|
||||
mock_conn = MagicMock(spec=acp.Client)
|
||||
mock_conn.session_update = AsyncMock()
|
||||
mock_conn.request_permission = AsyncMock()
|
||||
acp_agent._conn = mock_conn
|
||||
|
||||
def mock_run_conversation(user_message, conversation_history=None, task_id=None, **kwargs):
|
||||
"""Simulate an agent turn that calls terminal, gets a result, then responds."""
|
||||
agent = state.agent
|
||||
|
||||
# 1) Agent fires tool_progress_callback (ToolCallStart)
|
||||
if agent.tool_progress_callback:
|
||||
agent.tool_progress_callback(
|
||||
"tool.started", "terminal", "$ echo hello", {"command": "echo hello"}
|
||||
)
|
||||
|
||||
# 2) Agent fires step_callback with tool results (ToolCallUpdate)
|
||||
if agent.step_callback:
|
||||
agent.step_callback(1, [
|
||||
{"name": "terminal", "result": '{"output": "hello\\n", "exit_code": 0}'}
|
||||
])
|
||||
|
||||
return {
|
||||
"final_response": "The command output 'hello'.",
|
||||
"messages": [
|
||||
{"role": "user", "content": user_message},
|
||||
{"role": "assistant", "content": "The command output 'hello'."},
|
||||
],
|
||||
}
|
||||
|
||||
state.agent.run_conversation = mock_run_conversation
|
||||
|
||||
prompt = [TextContentBlock(type="text", text="run echo hello")]
|
||||
resp = await acp_agent.prompt(prompt=prompt, session_id=session_id)
|
||||
|
||||
assert isinstance(resp, PromptResponse)
|
||||
assert resp.stop_reason == "end_turn"
|
||||
|
||||
# Collect all session_update calls
|
||||
updates = []
|
||||
for call in mock_conn.session_update.call_args_list:
|
||||
# session_update(session_id, update) — grab the update
|
||||
update_arg = call[1].get("update") or call[0][1]
|
||||
updates.append(update_arg)
|
||||
|
||||
# Find tool_call (start) and tool_call_update (completion) events
|
||||
starts = [u for u in updates if getattr(u, "session_update", None) == "tool_call"]
|
||||
completions = [u for u in updates if getattr(u, "session_update", None) == "tool_call_update"]
|
||||
|
||||
# Should have at least one ToolCallStart for "terminal"
|
||||
assert len(starts) >= 1, f"Expected ToolCallStart, got updates: {[getattr(u, 'session_update', '?') for u in updates]}"
|
||||
start_event = starts[0]
|
||||
assert isinstance(start_event, ToolCallStart)
|
||||
assert start_event.title.startswith("terminal:")
|
||||
|
||||
# Should have at least one ToolCallUpdate (completion) with rawOutput
|
||||
assert len(completions) >= 1, f"Expected ToolCallUpdate, got updates: {[getattr(u, 'session_update', '?') for u in updates]}"
|
||||
complete_event = completions[0]
|
||||
assert isinstance(complete_event, ToolCallProgress)
|
||||
assert complete_event.status == "completed"
|
||||
# Completion should contain human-readable output rather than forcing raw JSON panes.
|
||||
assert complete_event.content
|
||||
assert "hello" in complete_event.content[0].content.text
|
||||
assert complete_event.raw_output is None
|
||||
|
||||
def test_patch_mode_tool_start_defers_diff_to_edit_approval_prompt(self):
|
||||
update = build_tool_start(
|
||||
"tc-1",
|
||||
"patch",
|
||||
{
|
||||
"mode": "patch",
|
||||
"patch": "*** Begin Patch\n*** Update File: src/app.py\n@@\n-old line\n+new line\n*** Add File: src/new.py\n+hello\n*** End Patch",
|
||||
},
|
||||
)
|
||||
|
||||
assert len(update.content) == 1
|
||||
assert update.content[0].type == "content"
|
||||
assert "Approval prompt shows the diff" in update.content[0].content.text
|
||||
|
||||
|
||||
|
||||
class TestMcpSanitizationE2E:
|
||||
"""Verify server names with special chars work end-to-end."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slashed_server_name_registers_cleanly(self, acp_agent, mock_manager):
|
||||
"""Server name 'ai.exa/exa' should not crash — tools get sanitized names."""
|
||||
servers = [
|
||||
McpServerHttp(
|
||||
name="ai.exa/exa",
|
||||
url="https://exa.ai/mcp",
|
||||
headers=[],
|
||||
),
|
||||
]
|
||||
|
||||
registered_configs = {}
|
||||
def mock_register(config_map):
|
||||
registered_configs.update(config_map)
|
||||
return ["mcp_ai_exa_exa_search"]
|
||||
|
||||
fake_tools = [{"function": {"name": "mcp_ai_exa_exa_search"}}]
|
||||
|
||||
with patch("tools.mcp_tool.register_mcp_servers", side_effect=mock_register), \
|
||||
patch("model_tools.get_tool_definitions", return_value=fake_tools):
|
||||
resp = await acp_agent.new_session(cwd="/tmp", mcp_servers=servers)
|
||||
|
||||
state = mock_manager.get_session(resp.session_id)
|
||||
|
||||
# Raw server name preserved as config key
|
||||
assert "ai.exa/exa" in registered_configs
|
||||
# Agent tools refreshed with sanitized name
|
||||
assert "mcp_ai_exa_exa_search" in state.agent.valid_tool_names
|
||||
|
||||
|
||||
class TestSessionLifecycleMcpE2E:
|
||||
"""Verify MCP servers are registered on all session lifecycle methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_registers_mcp(self, acp_agent, mock_manager):
|
||||
"""load_session re-registers MCP servers (spec says agents may not retain them)."""
|
||||
# Create a session first
|
||||
create_resp = await acp_agent.new_session(cwd="/tmp")
|
||||
sid = create_resp.session_id
|
||||
|
||||
servers = [
|
||||
McpServerStdio(name="srv", command="/bin/test", args=[], env=[]),
|
||||
]
|
||||
|
||||
registered = {}
|
||||
def mock_register(config_map):
|
||||
registered.update(config_map)
|
||||
return []
|
||||
|
||||
state = mock_manager.get_session(sid)
|
||||
state.agent.enabled_toolsets = ["hermes-acp"]
|
||||
state.agent.disabled_toolsets = None
|
||||
state.agent.tools = []
|
||||
state.agent.valid_tool_names = set()
|
||||
|
||||
with patch("tools.mcp_tool.register_mcp_servers", side_effect=mock_register), \
|
||||
patch("model_tools.get_tool_definitions", return_value=[]):
|
||||
await acp_agent.load_session(cwd="/tmp", session_id=sid, mcp_servers=servers)
|
||||
|
||||
assert "srv" in registered
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_session_registers_mcp(self, acp_agent, mock_manager):
|
||||
"""resume_session re-registers MCP servers."""
|
||||
create_resp = await acp_agent.new_session(cwd="/tmp")
|
||||
sid = create_resp.session_id
|
||||
|
||||
servers = [
|
||||
McpServerStdio(name="srv2", command="/bin/test2", args=[], env=[]),
|
||||
]
|
||||
|
||||
registered = {}
|
||||
def mock_register(config_map):
|
||||
registered.update(config_map)
|
||||
return []
|
||||
|
||||
state = mock_manager.get_session(sid)
|
||||
state.agent.enabled_toolsets = ["hermes-acp"]
|
||||
state.agent.disabled_toolsets = None
|
||||
state.agent.tools = []
|
||||
state.agent.valid_tool_names = set()
|
||||
|
||||
with patch("tools.mcp_tool.register_mcp_servers", side_effect=mock_register), \
|
||||
patch("model_tools.get_tool_definitions", return_value=[]):
|
||||
await acp_agent.resume_session(cwd="/tmp", session_id=sid, mcp_servers=servers)
|
||||
|
||||
assert "srv2" in registered
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_session_registers_mcp(self, acp_agent, mock_manager):
|
||||
"""fork_session registers MCP servers on the new forked session."""
|
||||
create_resp = await acp_agent.new_session(cwd="/tmp")
|
||||
sid = create_resp.session_id
|
||||
|
||||
servers = [
|
||||
McpServerHttp(name="api", url="https://api.test/mcp", headers=[]),
|
||||
]
|
||||
|
||||
registered = {}
|
||||
def mock_register(config_map):
|
||||
registered.update(config_map)
|
||||
return []
|
||||
|
||||
# Need to set up the forked session's agent too
|
||||
with patch("tools.mcp_tool.register_mcp_servers", side_effect=mock_register), \
|
||||
patch("model_tools.get_tool_definitions", return_value=[]):
|
||||
fork_resp = await acp_agent.fork_session(
|
||||
cwd="/tmp", session_id=sid, mcp_servers=servers
|
||||
)
|
||||
|
||||
assert fork_resp.session_id != ""
|
||||
assert "api" in registered
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Tests for named user-defined provider entries in the ACP model selector.
|
||||
|
||||
Named endpoints from the ``providers:`` mapping (and legacy
|
||||
``custom_providers:`` list) are invisible to canonical provider enumeration,
|
||||
so ``_build_model_state`` must append them explicitly for ACP clients to
|
||||
offer them — the TUI ``/model`` picker already renders these entries
|
||||
(#47039 implemented named endpoints for the TUI surface only).
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from acp_adapter.server import HermesACPAgent, _named_custom_provider_catalogs
|
||||
from acp_adapter.session import SessionManager
|
||||
from acp.schema import SessionModelState
|
||||
|
||||
|
||||
MANTLE_URL = "https://bedrock-mantle.us-east-1.api.aws/openai/v1"
|
||||
|
||||
|
||||
def _cfg(providers=None, custom_providers=None):
|
||||
cfg = {}
|
||||
if providers is not None:
|
||||
cfg["providers"] = providers
|
||||
if custom_providers is not None:
|
||||
cfg["custom_providers"] = custom_providers
|
||||
return cfg
|
||||
|
||||
|
||||
class TestNamedCustomProviderCatalogs:
|
||||
|
||||
def test_live_discovery_extends_declared_models(self, monkeypatch):
|
||||
monkeypatch.setenv("SOME_KEY", "k")
|
||||
cfg = _cfg(
|
||||
providers={
|
||||
"relay": {
|
||||
"name": "Relay",
|
||||
"base_url": "https://relay.example/v1",
|
||||
"key_env": "SOME_KEY",
|
||||
"default_model": "model-a",
|
||||
}
|
||||
}
|
||||
)
|
||||
with patch("hermes_cli.config.load_config", return_value=cfg), patch(
|
||||
"hermes_cli.model_switch._fetch_picker_live_models",
|
||||
return_value=["model-a", "model-b"],
|
||||
):
|
||||
catalogs = _named_custom_provider_catalogs()
|
||||
|
||||
assert len(catalogs) == 1
|
||||
slug, label, models = catalogs[0]
|
||||
assert slug == "custom:relay"
|
||||
assert [m for m, _ in models] == ["model-a", "model-b"]
|
||||
|
||||
|
||||
def test_disabled_provider_skipped(self, monkeypatch):
|
||||
monkeypatch.setenv("SOME_KEY", "k")
|
||||
cfg = _cfg(
|
||||
providers={
|
||||
"off": {
|
||||
"name": "Disabled Endpoint",
|
||||
"base_url": "https://off.example/v1",
|
||||
"key_env": "SOME_KEY",
|
||||
"default_model": "m",
|
||||
"enabled": False,
|
||||
}
|
||||
}
|
||||
)
|
||||
with patch("hermes_cli.config.load_config", return_value=cfg), patch(
|
||||
"hermes_cli.model_switch._fetch_picker_live_models", return_value=None
|
||||
):
|
||||
assert _named_custom_provider_catalogs() == []
|
||||
|
||||
def test_no_credential_and_no_declared_models_skipped(self, monkeypatch):
|
||||
monkeypatch.delenv("MISSING_KEY", raising=False)
|
||||
cfg = _cfg(
|
||||
providers={
|
||||
"bare": {
|
||||
"name": "Bare",
|
||||
"base_url": "https://bare.example/v1",
|
||||
"key_env": "MISSING_KEY",
|
||||
}
|
||||
}
|
||||
)
|
||||
with patch("hermes_cli.config.load_config", return_value=cfg), patch(
|
||||
"hermes_cli.model_switch._fetch_picker_live_models", return_value=None
|
||||
):
|
||||
assert _named_custom_provider_catalogs() == []
|
||||
|
||||
def test_legacy_custom_providers_list_included(self, monkeypatch):
|
||||
monkeypatch.setenv("SOME_KEY", "k")
|
||||
cfg = _cfg(
|
||||
custom_providers=[
|
||||
{
|
||||
"name": "Legacy Endpoint",
|
||||
"base_url": "https://legacy.example/v1",
|
||||
"key_env": "SOME_KEY",
|
||||
"model": "legacy-model",
|
||||
}
|
||||
]
|
||||
)
|
||||
with patch("hermes_cli.config.load_config", return_value=cfg), patch(
|
||||
"hermes_cli.model_switch._fetch_picker_live_models", return_value=None
|
||||
):
|
||||
catalogs = _named_custom_provider_catalogs()
|
||||
|
||||
assert catalogs == [
|
||||
("custom:legacy-endpoint", "Legacy Endpoint", [("legacy-model", "")])
|
||||
]
|
||||
def test_no_key_ollama_provider_discovers_native_catalog(self):
|
||||
cfg = _cfg(
|
||||
providers={
|
||||
"custom:ollama": {
|
||||
"name": "Ollama",
|
||||
"base_url": "http://127.0.0.1:11434/v1",
|
||||
}
|
||||
}
|
||||
)
|
||||
with patch("hermes_cli.config.load_config", return_value=cfg), patch(
|
||||
"hermes_cli.models.should_use_ollama_native_catalog",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"hermes_cli.model_switch._fetch_picker_live_models",
|
||||
return_value=["qwen3:1.7b"],
|
||||
) as fetch:
|
||||
catalogs = _named_custom_provider_catalogs()
|
||||
|
||||
assert [m for m, _ in catalogs[0][2]] == ["qwen3:1.7b"]
|
||||
fetch.assert_called_once_with(
|
||||
"",
|
||||
"http://127.0.0.1:11434/v1",
|
||||
"custom:ollama",
|
||||
False,
|
||||
headers=None,
|
||||
timeout=1.5,
|
||||
api_mode=None,
|
||||
)
|
||||
def test_legacy_credentialless_ollama_discovers_native_catalog(self):
|
||||
cfg = _cfg(
|
||||
custom_providers=[
|
||||
{
|
||||
"name": "Local Ollama",
|
||||
"base_url": "http://127.0.0.1:11434/v1",
|
||||
}
|
||||
]
|
||||
)
|
||||
with patch("hermes_cli.config.load_config", return_value=cfg), patch(
|
||||
"hermes_cli.models.should_use_ollama_native_catalog",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"hermes_cli.model_switch._fetch_picker_live_models",
|
||||
return_value=["qwen3:1.7b"],
|
||||
) as fetch:
|
||||
catalogs = _named_custom_provider_catalogs()
|
||||
|
||||
assert [m for m, _ in catalogs[0][2]] == ["qwen3:1.7b"]
|
||||
fetch.assert_called_once()
|
||||
|
||||
def test_native_empty_catalog_is_authoritative_over_default_model(self):
|
||||
cfg = _cfg(
|
||||
providers={
|
||||
"custom:ollama": {
|
||||
"name": "Ollama",
|
||||
"base_url": "http://127.0.0.1:11434/v1",
|
||||
"default_model": "saved:model",
|
||||
}
|
||||
}
|
||||
)
|
||||
from hermes_cli.model_switch import _NativePickerModelList
|
||||
|
||||
with patch("hermes_cli.config.load_config", return_value=cfg), patch(
|
||||
"hermes_cli.models.should_use_ollama_native_catalog",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"hermes_cli.model_switch._fetch_picker_live_models",
|
||||
return_value=_NativePickerModelList(),
|
||||
):
|
||||
assert _named_custom_provider_catalogs() == [
|
||||
("custom:ollama", "Ollama", [])
|
||||
]
|
||||
|
||||
|
||||
class TestModelStateIncludesNamedProviders:
|
||||
@pytest.mark.asyncio
|
||||
async def test_authoritative_empty_named_catalog_does_not_resurrect_current_model(self):
|
||||
manager = SessionManager(
|
||||
agent_factory=lambda: SimpleNamespace(
|
||||
model="saved:model", provider="ollama"
|
||||
)
|
||||
)
|
||||
acp_agent = HermesACPAgent(session_manager=manager)
|
||||
|
||||
with patch("hermes_cli.models.curated_models_for_provider", return_value=[]), patch(
|
||||
"acp_adapter.server._named_custom_provider_catalogs",
|
||||
return_value=[("custom:ollama", "Ollama", [])],
|
||||
):
|
||||
resp = await acp_agent.new_session(cwd="/tmp")
|
||||
|
||||
assert isinstance(resp.models, SessionModelState)
|
||||
assert resp.models.current_model_id == ""
|
||||
assert all(
|
||||
not item.model_id.startswith(("ollama:", "custom:ollama:"))
|
||||
for item in resp.models.available_models
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_named_provider_models_appear_in_model_state(self):
|
||||
manager = SessionManager(
|
||||
agent_factory=lambda: SimpleNamespace(
|
||||
model="gpt-5.4", provider="openai-codex"
|
||||
)
|
||||
)
|
||||
acp_agent = HermesACPAgent(session_manager=manager)
|
||||
|
||||
with patch(
|
||||
"hermes_cli.models.curated_models_for_provider",
|
||||
return_value=[("gpt-5.4", "recommended")],
|
||||
), patch(
|
||||
"acp_adapter.server._named_custom_provider_catalogs",
|
||||
return_value=[
|
||||
(
|
||||
"custom:bedrock-mantle",
|
||||
"AWS Bedrock Mantle",
|
||||
[("openai.gpt-5.5", "")],
|
||||
)
|
||||
],
|
||||
):
|
||||
resp = await acp_agent.new_session(cwd="/tmp")
|
||||
|
||||
assert isinstance(resp.models, SessionModelState)
|
||||
ids = [m.model_id for m in resp.models.available_models]
|
||||
# Current provider's models come first, named endpoints after.
|
||||
assert ids[0] == "openai-codex:gpt-5.4"
|
||||
assert "custom:bedrock-mantle:openai.gpt-5.5" in ids
|
||||
named = next(
|
||||
m
|
||||
for m in resp.models.available_models
|
||||
if m.model_id == "custom:bedrock-mantle:openai.gpt-5.5"
|
||||
)
|
||||
assert "AWS Bedrock Mantle" in (named.description or "")
|
||||
|
||||
def test_selector_choice_id_round_trips_through_parse_model_input(self):
|
||||
"""The encoded choice id must resolve back to the named provider."""
|
||||
from hermes_cli.models import parse_model_input
|
||||
|
||||
choice_id = "custom:bedrock-mantle:openai.gpt-5.5"
|
||||
cfg = {
|
||||
"providers": {
|
||||
"bedrock-mantle": {
|
||||
"name": "AWS Bedrock Mantle",
|
||||
"base_url": "https://bedrock.example/v1",
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch("hermes_cli.config.load_config", return_value=cfg):
|
||||
provider, model = parse_model_input(choice_id, "bedrock")
|
||||
assert provider == "custom:bedrock-mantle"
|
||||
assert model == "openai.gpt-5.5"
|
||||
|
||||
def test_selector_choice_id_round_trips_colon_bearing_custom_identity(self):
|
||||
"""Configured provider and model IDs may both contain colons."""
|
||||
from hermes_cli.models import parse_model_input
|
||||
|
||||
cfg = {
|
||||
"providers": {
|
||||
"local-127.0.0.1:11434": {
|
||||
"name": "Local Ollama",
|
||||
"base_url": "http://127.0.0.1:11434/v1",
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch("hermes_cli.config.load_config", return_value=cfg):
|
||||
provider, model = parse_model_input(
|
||||
"custom:local-127.0.0.1:11434:qwen3:1.7b", "custom"
|
||||
)
|
||||
assert provider == "custom:local-127.0.0.1:11434"
|
||||
assert model == "qwen3:1.7b"
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Tests for acp_adapter.permissions."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from concurrent.futures import Future
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from acp.schema import (
|
||||
AllowedOutcome,
|
||||
DeniedOutcome,
|
||||
RequestPermissionResponse,
|
||||
)
|
||||
|
||||
from acp_adapter.permissions import make_approval_callback
|
||||
from tools.approval import prompt_dangerous_approval
|
||||
|
||||
|
||||
def _make_response(outcome):
|
||||
return RequestPermissionResponse(outcome=outcome)
|
||||
|
||||
|
||||
def _invoke_callback(
|
||||
outcome,
|
||||
*,
|
||||
allow_permanent=True,
|
||||
allow_session=True,
|
||||
smart_denied=False,
|
||||
timeout=60.0,
|
||||
use_prompt_path=False,
|
||||
):
|
||||
loop = MagicMock(spec=asyncio.AbstractEventLoop)
|
||||
request_permission = AsyncMock(name="request_permission")
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.return_value = _make_response(outcome)
|
||||
|
||||
scheduled = {}
|
||||
|
||||
def _schedule(coro, passed_loop):
|
||||
scheduled["coro"] = coro
|
||||
scheduled["loop"] = passed_loop
|
||||
return future
|
||||
|
||||
with patch("agent.async_utils.asyncio.run_coroutine_threadsafe", side_effect=_schedule):
|
||||
cb = make_approval_callback(request_permission, loop, session_id="s1", timeout=timeout)
|
||||
if use_prompt_path:
|
||||
result = prompt_dangerous_approval(
|
||||
"rm -rf /",
|
||||
"dangerous command",
|
||||
allow_permanent=allow_permanent,
|
||||
allow_session=allow_session,
|
||||
smart_denied=smart_denied,
|
||||
approval_callback=cb,
|
||||
)
|
||||
else:
|
||||
result = cb(
|
||||
"rm -rf /",
|
||||
"dangerous command",
|
||||
allow_permanent=allow_permanent,
|
||||
allow_session=allow_session,
|
||||
smart_denied=smart_denied,
|
||||
)
|
||||
|
||||
scheduled["coro"].close()
|
||||
_, kwargs = request_permission.call_args
|
||||
return result, kwargs, scheduled, future, loop
|
||||
|
||||
|
||||
class TestApprovalBridge:
|
||||
def test_bridge_schedules_request_on_the_given_loop(self):
|
||||
result, kwargs, scheduled, _, loop = _invoke_callback(
|
||||
AllowedOutcome(option_id="allow_once", outcome="selected"),
|
||||
)
|
||||
|
||||
tool_call = kwargs["tool_call"]
|
||||
option_ids = [option.option_id for option in kwargs["options"]]
|
||||
|
||||
assert result == "once"
|
||||
assert scheduled["loop"] is loop
|
||||
assert inspect.iscoroutine(scheduled["coro"])
|
||||
assert kwargs["session_id"] == "s1"
|
||||
assert tool_call.session_update == "tool_call_update"
|
||||
assert tool_call.tool_call_id.startswith("perm-check-")
|
||||
assert tool_call.kind == "execute"
|
||||
assert tool_call.status == "pending"
|
||||
assert "dangerous command" in tool_call.title
|
||||
assert "rm -rf /" in tool_call.title
|
||||
content_text = tool_call.content[0].content.text
|
||||
assert "$ rm -rf /" in content_text
|
||||
assert "dangerous command" in content_text
|
||||
assert tool_call.raw_input == {
|
||||
"command": "rm -rf /",
|
||||
"description": "dangerous command",
|
||||
}
|
||||
assert option_ids == [
|
||||
"allow_once",
|
||||
"allow_session",
|
||||
"allow_always",
|
||||
"deny",
|
||||
"deny_always",
|
||||
]
|
||||
|
||||
def test_session_less_gate_offers_only_once_and_deny(self):
|
||||
"""allow_session=False collapses the editor menu to once/deny.
|
||||
|
||||
Hermes discards any scope broader than one operation for the
|
||||
protected agent-instruction gate, so an editor that renders
|
||||
"Allow for session" would re-prompt on the next write (#81887).
|
||||
"""
|
||||
_, kwargs, _, _, _ = _invoke_callback(
|
||||
AllowedOutcome(option_id="allow_once", outcome="selected"),
|
||||
allow_permanent=False,
|
||||
allow_session=False,
|
||||
)
|
||||
|
||||
assert [option.option_id for option in kwargs["options"]] == ["allow_once", "deny"]
|
||||
|
||||
def test_tool_call_ids_are_unique(self):
|
||||
_, first_kwargs, _, _, _ = _invoke_callback(
|
||||
AllowedOutcome(option_id="allow_once", outcome="selected"),
|
||||
)
|
||||
_, second_kwargs, _, _, _ = _invoke_callback(
|
||||
AllowedOutcome(option_id="allow_once", outcome="selected"),
|
||||
)
|
||||
|
||||
assert first_kwargs["tool_call"].tool_call_id != second_kwargs["tool_call"].tool_call_id
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_allow_always_maps_correctly(self):
|
||||
result, _, _, _, _ = _invoke_callback(
|
||||
AllowedOutcome(option_id="allow_always", outcome="selected"),
|
||||
use_prompt_path=True,
|
||||
)
|
||||
|
||||
assert result == "always"
|
||||
|
||||
|
||||
def test_timeout_returns_timeout_and_cancels_future(self):
|
||||
loop = MagicMock(spec=asyncio.AbstractEventLoop)
|
||||
request_permission = AsyncMock(name="request_permission")
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.side_effect = TimeoutError("timed out")
|
||||
|
||||
scheduled = {}
|
||||
|
||||
def _schedule(coro, passed_loop):
|
||||
scheduled["coro"] = coro
|
||||
scheduled["loop"] = passed_loop
|
||||
return future
|
||||
|
||||
with patch("agent.async_utils.asyncio.run_coroutine_threadsafe", side_effect=_schedule):
|
||||
cb = make_approval_callback(request_permission, loop, session_id="s1", timeout=0.01)
|
||||
result = cb("rm -rf /", "dangerous command")
|
||||
|
||||
scheduled["coro"].close()
|
||||
|
||||
# A no-response expiry is classified as "timeout" (still blocked,
|
||||
# fail-closed) so the agent isn't told the user explicitly refused.
|
||||
assert result == "timeout"
|
||||
assert scheduled["loop"] is loop
|
||||
assert future.cancel.call_count == 1
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scheduler-failure regression
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import gc # noqa: E402
|
||||
import warnings # noqa: E402
|
||||
|
||||
|
||||
class TestSchedulerFailure:
|
||||
def test_scheduler_failure_closes_permission_coroutine(self):
|
||||
"""If run_coroutine_threadsafe raises, the coro is closed and we return 'deny'."""
|
||||
loop = MagicMock(spec=asyncio.AbstractEventLoop)
|
||||
created = {"coro": None}
|
||||
|
||||
async def _response_coro(**kwargs):
|
||||
return _make_response(AllowedOutcome(option_id="allow_once", outcome="selected"))
|
||||
|
||||
def _request_permission(**kwargs):
|
||||
created["coro"] = _response_coro(**kwargs)
|
||||
return created["coro"]
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
with patch(
|
||||
"agent.async_utils.asyncio.run_coroutine_threadsafe",
|
||||
side_effect=RuntimeError("scheduler down"),
|
||||
):
|
||||
cb = make_approval_callback(_request_permission, loop, session_id="s1", timeout=0.01)
|
||||
result = cb("rm -rf /", "dangerous")
|
||||
gc.collect()
|
||||
|
||||
assert result == "deny"
|
||||
assert created["coro"] is not None
|
||||
assert created["coro"].cr_frame is None
|
||||
runtime_warnings = [
|
||||
w for w in caught
|
||||
if issubclass(w.category, RuntimeWarning)
|
||||
and "was never awaited" in str(w.message)
|
||||
and "_response_coro" in str(w.message)
|
||||
]
|
||||
assert runtime_warnings == []
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Tests for acp_adapter.entry._BenignProbeMethodFilter.
|
||||
|
||||
Covers both the isolated filter logic and the full end-to-end path where a
|
||||
client sends a bare JSON-RPC ``ping`` request over stdio and the acp runtime
|
||||
surfaces the resulting ``RequestError`` via ``logging.exception("Background
|
||||
task failed", ...)``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from io import StringIO
|
||||
|
||||
import pytest
|
||||
|
||||
from acp.exceptions import RequestError
|
||||
|
||||
from acp_adapter.entry import _BenignProbeMethodFilter
|
||||
|
||||
|
||||
# -- Unit tests on the filter itself ----------------------------------------
|
||||
|
||||
|
||||
def _make_record(msg: str, exc: BaseException | None) -> logging.LogRecord:
|
||||
record = logging.LogRecord(
|
||||
name="root",
|
||||
level=logging.ERROR,
|
||||
pathname=__file__,
|
||||
lineno=0,
|
||||
msg=msg,
|
||||
args=(),
|
||||
exc_info=(type(exc), exc, exc.__traceback__) if exc else None,
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def _bake_tb(exc: BaseException) -> BaseException:
|
||||
try:
|
||||
raise exc
|
||||
except BaseException as e: # noqa: BLE001
|
||||
return e
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["ping", "health", "healthcheck"])
|
||||
def test_filter_suppresses_benign_probe(method: str) -> None:
|
||||
f = _BenignProbeMethodFilter()
|
||||
exc = _bake_tb(RequestError.method_not_found(method))
|
||||
record = _make_record("Background task failed", exc)
|
||||
assert f.filter(record) is False
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_filter_allows_different_message_even_for_ping() -> None:
|
||||
"""Only 'Background task failed' is muted — other messages pass through."""
|
||||
f = _BenignProbeMethodFilter()
|
||||
exc = _bake_tb(RequestError.method_not_found("ping"))
|
||||
record = _make_record("Some other context", exc)
|
||||
assert f.filter(record) is True
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# -- End-to-end: drive a real JSON-RPC `ping` through acp.run_agent ---------
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
"""Minimal acp.Agent stub — we only need the router to build."""
|
||||
|
||||
async def initialize(self, **kwargs): # noqa: ANN003
|
||||
from acp.schema import AgentCapabilities, InitializeResponse
|
||||
|
||||
return InitializeResponse(protocol_version=1, agent_capabilities=AgentCapabilities())
|
||||
|
||||
async def new_session(self, cwd, mcp_servers=None, **kwargs): # noqa: ANN001, ANN003
|
||||
from acp.schema import NewSessionResponse
|
||||
|
||||
return NewSessionResponse(session_id="test")
|
||||
|
||||
async def prompt(self, session_id, prompt, **kwargs): # noqa: ANN001, ANN003
|
||||
from acp.schema import PromptResponse
|
||||
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
async def cancel(self, session_id, **kwargs): # noqa: ANN001, ANN003
|
||||
pass
|
||||
|
||||
async def authenticate(self, **kwargs): # noqa: ANN003
|
||||
pass
|
||||
|
||||
def on_connect(self, conn): # noqa: ANN001
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_ping_request_produces_proper_response_and_no_stderr_noise(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A bare ``ping`` must get a JSON-RPC -32601 back AND leave stderr clean
|
||||
when the filter is installed on the handler.
|
||||
"""
|
||||
import acp
|
||||
|
||||
# Attach the filter to a fresh stream handler that mirrors entry._setup_logging.
|
||||
stream = StringIO()
|
||||
handler = logging.StreamHandler(stream)
|
||||
handler.setFormatter(logging.Formatter("%(name)s|%(levelname)s|%(message)s"))
|
||||
handler.addFilter(_BenignProbeMethodFilter())
|
||||
root = logging.getLogger()
|
||||
prior_handlers = root.handlers[:]
|
||||
prior_level = root.level
|
||||
root.handlers = [handler]
|
||||
root.setLevel(logging.INFO)
|
||||
# Also suppress propagation of caplog's default handler interfering with
|
||||
# our stream (caplog still captures via its own propagation hook).
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Pipe client -> agent
|
||||
client_to_agent_r, client_to_agent_w = os.pipe()
|
||||
# Pipe agent -> client
|
||||
agent_to_client_r, agent_to_client_w = os.pipe()
|
||||
|
||||
in_read_file = os.fdopen(client_to_agent_r, "rb", buffering=0)
|
||||
in_write_file = os.fdopen(client_to_agent_w, "wb", buffering=0)
|
||||
out_read_file = os.fdopen(agent_to_client_r, "rb", buffering=0)
|
||||
out_write_file = os.fdopen(agent_to_client_w, "wb", buffering=0)
|
||||
|
||||
# Agent reads its input from this StreamReader:
|
||||
agent_input = asyncio.StreamReader(limit=1024 * 1024, loop=loop)
|
||||
agent_input_proto = asyncio.StreamReaderProtocol(agent_input, loop=loop)
|
||||
await loop.connect_read_pipe(lambda: agent_input_proto, in_read_file)
|
||||
|
||||
# Agent writes its output via this StreamWriter:
|
||||
out_transport, out_protocol = await loop.connect_write_pipe(
|
||||
asyncio.streams.FlowControlMixin, out_write_file
|
||||
)
|
||||
agent_output = asyncio.StreamWriter(out_transport, out_protocol, None, loop)
|
||||
|
||||
# Test harness reads agent output via this StreamReader:
|
||||
client_input = asyncio.StreamReader(limit=1024 * 1024, loop=loop)
|
||||
client_input_proto = asyncio.StreamReaderProtocol(client_input, loop=loop)
|
||||
await loop.connect_read_pipe(lambda: client_input_proto, out_read_file)
|
||||
|
||||
agent_task = asyncio.create_task(
|
||||
acp.run_agent(
|
||||
_FakeAgent(),
|
||||
input_stream=agent_output,
|
||||
output_stream=agent_input,
|
||||
use_unstable_protocol=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Send a bare `ping`
|
||||
request = {"jsonrpc": "2.0", "id": 1, "method": "ping", "params": {}}
|
||||
in_write_file.write((json.dumps(request) + "\n").encode())
|
||||
in_write_file.flush()
|
||||
|
||||
response_line = await asyncio.wait_for(client_input.readline(), timeout=5.0)
|
||||
# Give the supervisor task a tick to fire (filter should eat it)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
response = json.loads(response_line.decode())
|
||||
assert response["error"]["code"] == -32601, response
|
||||
assert response["error"]["data"] == {"method": "ping"}, response
|
||||
|
||||
logs = stream.getvalue()
|
||||
assert "Background task failed" not in logs, (
|
||||
f"ping noise leaked to stderr:\n{logs}"
|
||||
)
|
||||
|
||||
# Clean shutdown
|
||||
in_write_file.close()
|
||||
try:
|
||||
await asyncio.wait_for(agent_task, timeout=2.0)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
agent_task.cancel()
|
||||
try:
|
||||
await agent_task
|
||||
except BaseException: # noqa: BLE001
|
||||
pass
|
||||
finally:
|
||||
root.handlers = prior_handlers
|
||||
root.setLevel(prior_level)
|
||||
@@ -0,0 +1,728 @@
|
||||
"""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"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_binds_session_id_into_subprocess_env(self, agent, mock_manager):
|
||||
"""The ACP prompt path must bridge the session id into child subprocesses.
|
||||
|
||||
Regression: ``set_session_vars`` was called with ``session_key`` only,
|
||||
leaving the ``HERMES_SESSION_ID`` ContextVar bound to the explicit ""
|
||||
default. Once the session-context machinery is engaged, that empty value
|
||||
is authoritative — so ``_make_run_env`` handed child subprocesses an
|
||||
empty ``HERMES_SESSION_ID`` instead of the session's own id.
|
||||
"""
|
||||
from tools.environments.local import _make_run_env
|
||||
|
||||
resp = await agent.new_session(cwd=".")
|
||||
state = mock_manager.get_session(resp.session_id)
|
||||
|
||||
captured: dict[str, str | None] = {}
|
||||
|
||||
def _run(*args, **kwargs):
|
||||
# Runs inside the session context copy set up by prompt().
|
||||
captured["child"] = _make_run_env({}).get("HERMES_SESSION_ID")
|
||||
return {"final_response": "ok", "messages": []}
|
||||
|
||||
state.agent.run_conversation = _run
|
||||
state.agent.model = "test-model"
|
||||
state.agent.provider = "openrouter"
|
||||
|
||||
mock_conn = MagicMock(spec=acp.Client)
|
||||
mock_conn.session_update = AsyncMock()
|
||||
agent._conn = mock_conn
|
||||
|
||||
await agent.prompt(
|
||||
prompt=[TextContentBlock(type="text", text="hi")],
|
||||
session_id=resp.session_id,
|
||||
)
|
||||
|
||||
assert captured.get("child") == resp.session_id
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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])
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Tests for acp_adapter.session — SessionManager and SessionState."""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from acp_adapter import session as acp_session
|
||||
from acp_adapter.session import SessionManager, SessionState
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
def _mock_agent():
|
||||
return MagicMock(name="MockAIAgent")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def manager():
|
||||
"""SessionManager with a mock agent factory (avoids needing API keys)."""
|
||||
return SessionManager(agent_factory=_mock_agent)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create / get
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateSession:
|
||||
def test_create_session_returns_state(self, manager):
|
||||
state = manager.create_session(cwd="/tmp/work")
|
||||
assert isinstance(state, SessionState)
|
||||
assert state.cwd == "/tmp/work"
|
||||
assert state.session_id
|
||||
assert state.history == []
|
||||
assert state.agent is not None
|
||||
|
||||
|
||||
|
||||
def test_register_task_cwd_translates_windows_drive_for_wsl_tools(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_register_task_env_overrides(task_id, overrides):
|
||||
captured["task_id"] = task_id
|
||||
captured["overrides"] = overrides
|
||||
|
||||
monkeypatch.setattr("hermes_constants._wsl_detected", True)
|
||||
monkeypatch.setattr(
|
||||
"tools.terminal_tool.register_task_env_overrides",
|
||||
fake_register_task_env_overrides,
|
||||
)
|
||||
|
||||
acp_session._register_task_cwd("session-1", r"E:\Projects\AI\paperclip")
|
||||
|
||||
assert captured == {
|
||||
"task_id": "session-1",
|
||||
"overrides": {"cwd": "/mnt/e/Projects/AI/paperclip"},
|
||||
}
|
||||
|
||||
|
||||
def test_get_session(self, manager):
|
||||
state = manager.create_session()
|
||||
fetched = manager.get_session(state.session_id)
|
||||
assert fetched is state
|
||||
|
||||
|
||||
def test_make_agent_stamps_session_cwd_for_codex_runtime(self, monkeypatch):
|
||||
class FakeAgent:
|
||||
model = "fake-model"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
monkeypatch.setattr("run_agent.AIAgent", FakeAgent)
|
||||
monkeypatch.setattr(
|
||||
"acp_adapter.session.load_config",
|
||||
lambda: {
|
||||
"model": {
|
||||
"default": "fake-model",
|
||||
"provider": "fake-provider",
|
||||
},
|
||||
"mcp_servers": {},
|
||||
},
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {
|
||||
"model": {
|
||||
"default": "fake-model",
|
||||
"provider": "fake-provider",
|
||||
},
|
||||
"mcp_servers": {},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda requested=None: {
|
||||
"provider": requested,
|
||||
"api_mode": "codex_app_server",
|
||||
"base_url": "https://example.invalid",
|
||||
"api_key": "test-key",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr("acp_adapter.session._register_task_cwd", lambda task_id, cwd: None)
|
||||
|
||||
state = SessionManager(db=None).create_session(cwd="/tmp/project")
|
||||
|
||||
assert state.agent.session_cwd == "/tmp/project"
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WSL cwd translation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWslCwdTranslation:
|
||||
def test_translate_acp_cwd_converts_windows_drive_path_when_wsl(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_constants._wsl_detected", True)
|
||||
|
||||
assert acp_session._translate_acp_cwd(r"E:\Projects\AI\paperclip") == "/mnt/e/Projects/AI/paperclip"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_fork_session_stores_translated_cwd_on_wsl(self, manager, monkeypatch):
|
||||
monkeypatch.setattr("hermes_constants._wsl_detected", True)
|
||||
original = manager.create_session(cwd="/tmp/base")
|
||||
|
||||
forked = manager.fork_session(original.session_id, cwd=r"D:\work\project")
|
||||
|
||||
assert forked is not None
|
||||
assert forked.cwd == "/mnt/d/work/project"
|
||||
|
||||
def test_update_cwd_stores_translated_cwd_on_wsl(self, manager, monkeypatch):
|
||||
monkeypatch.setattr("hermes_constants._wsl_detected", True)
|
||||
state = manager.create_session(cwd="/tmp/old")
|
||||
|
||||
updated = manager.update_cwd(state.session_id, cwd=r"C:\Users\foo\project")
|
||||
|
||||
assert updated is not None
|
||||
assert updated.cwd == "/mnt/c/Users/foo/project"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fork
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list / cleanup / remove
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSymlinkAliasNormalization:
|
||||
"""Ported from PrimeIntellect-ai/prime-agent#628 — symlink aliases of the
|
||||
same directory (macOS ``/var`` vs ``/private/var``, ``/tmp`` vs
|
||||
``/private/tmp``) must compare equal, or ACP history filters silently drop
|
||||
a workspace's own sessions."""
|
||||
|
||||
def test_symlink_alias_compares_equal(self, tmp_path):
|
||||
real = tmp_path / "real"
|
||||
real.mkdir()
|
||||
alias = tmp_path / "alias"
|
||||
alias.symlink_to(real)
|
||||
assert acp_session._normalize_cwd_for_compare(
|
||||
str(alias)
|
||||
) == acp_session._normalize_cwd_for_compare(str(real))
|
||||
|
||||
def test_distinct_dirs_still_compare_different(self, tmp_path):
|
||||
a = tmp_path / "a"
|
||||
b = tmp_path / "b"
|
||||
a.mkdir()
|
||||
b.mkdir()
|
||||
assert acp_session._normalize_cwd_for_compare(
|
||||
str(a)
|
||||
) != acp_session._normalize_cwd_for_compare(str(b))
|
||||
|
||||
def test_missing_path_keeps_lexical_normalization(self):
|
||||
# realpath(strict=False) is lexical for nonexistent paths, so cwds
|
||||
# that don't exist on this host (e.g. WSL-translated drives) behave
|
||||
# exactly as the old normpath comparison did.
|
||||
assert acp_session._normalize_cwd_for_compare(
|
||||
"/nonexistent-hermes-test/x/../y"
|
||||
) == "/nonexistent-hermes-test/y"
|
||||
|
||||
def test_list_sessions_matches_symlink_alias_cwd(self, manager, tmp_path):
|
||||
real = tmp_path / "proj"
|
||||
real.mkdir()
|
||||
alias = tmp_path / "link"
|
||||
alias.symlink_to(real)
|
||||
state = manager.create_session(cwd=str(real))
|
||||
state.history.append({"role": "user", "content": "hello"})
|
||||
listed = manager.list_sessions(cwd=str(alias))
|
||||
assert [s["session_id"] for s in listed] == [state.session_id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list / cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListAndCleanup:
|
||||
def test_list_sessions_empty(self, manager):
|
||||
assert manager.list_sessions() == []
|
||||
|
||||
|
||||
|
||||
def test_save_session_preserves_existing_messages_on_encode_failure(self, manager):
|
||||
"""Regression for #13675: a bad message in state.history must not
|
||||
clobber the previously-persisted transcript. replace_messages()
|
||||
wraps DELETE + INSERT in a single rolled-back-on-exception txn.
|
||||
"""
|
||||
state = manager.create_session()
|
||||
state.history.append({"role": "user", "content": "original"})
|
||||
manager.save_session(state.session_id)
|
||||
|
||||
# Now swap history with a message whose tool_calls is non-JSON-serializable.
|
||||
# _execute_write rolls back; the previously persisted "original" stays.
|
||||
state.history = [
|
||||
{"role": "user", "content": "replacement"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{"bad": object()}],
|
||||
},
|
||||
]
|
||||
manager.save_session(state.session_id)
|
||||
|
||||
db = manager._get_db()
|
||||
messages = db.get_messages_as_conversation(state.session_id)
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["role"] == "user"
|
||||
assert messages[0]["content"] == "original"
|
||||
assert isinstance(messages[0].get("timestamp"), (int, float))
|
||||
|
||||
|
||||
|
||||
|
||||
def test_cleanup_clears_all(self, manager):
|
||||
s1 = manager.create_session()
|
||||
s2 = manager.create_session()
|
||||
s1.history.append({"role": "user", "content": "one"})
|
||||
s2.history.append({"role": "user", "content": "two"})
|
||||
assert len(manager.list_sessions()) == 2
|
||||
manager.cleanup()
|
||||
assert manager.list_sessions() == []
|
||||
|
||||
def test_remove_session(self, manager):
|
||||
state = manager.create_session()
|
||||
assert manager.remove_session(state.session_id) is True
|
||||
assert manager.get_session(state.session_id) is None
|
||||
# Removing again returns False
|
||||
assert manager.remove_session(state.session_id) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# persistence — sessions survive process restarts (via SessionDB)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPersistence:
|
||||
"""Verify that sessions are persisted to SessionDB and can be restored."""
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_only_restores_acp_sessions(self, manager):
|
||||
"""get_session should not restore non-ACP sessions from DB."""
|
||||
db = manager._get_db()
|
||||
# Manually create a CLI session in the DB.
|
||||
db.create_session(session_id="cli-session-123", source="cli", model="test")
|
||||
# Should not be found via ACP SessionManager.
|
||||
assert manager.get_session("cli-session-123") is None
|
||||
|
||||
def test_sessions_searchable_via_fts(self, manager):
|
||||
"""ACP sessions stored in SessionDB are searchable via FTS5."""
|
||||
state = manager.create_session()
|
||||
state.history.append({"role": "user", "content": "how do I configure nginx"})
|
||||
state.history.append({"role": "assistant", "content": "Here is the nginx config..."})
|
||||
manager.save_session(state.session_id)
|
||||
|
||||
db = manager._get_db()
|
||||
results = db.search_messages("nginx")
|
||||
assert len(results) > 0
|
||||
session_ids = {r["session_id"] for r in results}
|
||||
assert state.session_id in session_ids
|
||||
|
||||
|
||||
def test_assistant_reasoning_fields_persisted(self, manager):
|
||||
"""ACP session restore should preserve assistant reasoning context."""
|
||||
state = manager.create_session()
|
||||
state.history.append({
|
||||
"role": "assistant",
|
||||
"content": "hello",
|
||||
"reasoning": "step-by-step",
|
||||
"reasoning_details": [
|
||||
{"type": "thinking", "thinking": "first thought"},
|
||||
],
|
||||
"codex_reasoning_items": [
|
||||
{"type": "reasoning", "id": "rs_123", "encrypted_content": "enc_blob"},
|
||||
],
|
||||
})
|
||||
manager.save_session(state.session_id)
|
||||
|
||||
with manager._lock:
|
||||
del manager._sessions[state.session_id]
|
||||
|
||||
restored = manager.get_session(state.session_id)
|
||||
assert restored is not None
|
||||
msg = restored.history[0]
|
||||
assert isinstance(msg.pop("timestamp", None), (int, float))
|
||||
# Load-time durability stamp (#92231): rows materialized from the DB
|
||||
# are marked persisted so a later flush can't re-append them.
|
||||
assert msg.pop("_db_persisted", None) is True
|
||||
assert restored.history == [{
|
||||
"role": "assistant",
|
||||
"content": "hello",
|
||||
"reasoning": "step-by-step",
|
||||
"reasoning_details": [
|
||||
{"type": "thinking", "thinking": "first thought"},
|
||||
],
|
||||
"codex_reasoning_items": [
|
||||
{"type": "reasoning", "id": "rs_123", "encrypted_content": "enc_blob"},
|
||||
],
|
||||
}]
|
||||
|
||||
|
||||
def test_acp_agents_route_human_output_to_stderr(self, tmp_path, monkeypatch):
|
||||
"""ACP agents must keep stdout clean for JSON-RPC stdio transport."""
|
||||
|
||||
def fake_resolve_runtime_provider(requested=None, **kwargs):
|
||||
return {
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": "https://openrouter.example/v1",
|
||||
"api_key": "test-key",
|
||||
"command": None,
|
||||
"args": [],
|
||||
}
|
||||
|
||||
def fake_agent(**kwargs):
|
||||
return SimpleNamespace(model=kwargs.get("model"), _print_fn=None)
|
||||
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {
|
||||
"model": {"provider": "openrouter", "default": "test-model"}
|
||||
})
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
fake_resolve_runtime_provider,
|
||||
)
|
||||
db = SessionDB(tmp_path / "state.db")
|
||||
|
||||
with patch("run_agent.AIAgent", side_effect=fake_agent):
|
||||
manager = SessionManager(db=db)
|
||||
state = manager.create_session(cwd="/work")
|
||||
|
||||
stdout_buf = io.StringIO()
|
||||
stderr_buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout_buf), contextlib.redirect_stderr(stderr_buf):
|
||||
state.agent._print_fn("ACP noise")
|
||||
|
||||
assert stdout_buf.getvalue() == ""
|
||||
assert stderr_buf.getvalue() == "ACP noise\n"
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Tests for the update_session_meta fix.
|
||||
|
||||
Verifies that:
|
||||
1. SessionDB.update_session_meta() exists and works correctly via the
|
||||
public _execute_write path (not db._lock / db._conn directly).
|
||||
2. session.py _persist() no longer touches db._lock or db._conn.
|
||||
3. update_session_meta updates the correct columns atomically.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
from acp_adapter.session import SessionManager
|
||||
|
||||
|
||||
def _tmp_db(tmp_path):
|
||||
return SessionDB(db_path=tmp_path / "state.db")
|
||||
|
||||
|
||||
def _mock_agent():
|
||||
return MagicMock(name="MockAIAgent")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hermes_state.SessionDB.update_session_meta — unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUpdateSessionMeta:
|
||||
"""Direct unit tests for the new public method."""
|
||||
|
||||
def test_method_exists(self, tmp_path):
|
||||
db = _tmp_db(tmp_path)
|
||||
assert hasattr(db, "update_session_meta"), (
|
||||
"SessionDB must have update_session_meta() public method"
|
||||
)
|
||||
assert callable(db.update_session_meta)
|
||||
|
||||
def test_updates_model_config(self, tmp_path):
|
||||
db = _tmp_db(tmp_path)
|
||||
db.create_session("s1", source="acp", model="gpt-4")
|
||||
|
||||
new_meta = json.dumps({"cwd": "/new/path", "provider": "openai"})
|
||||
db.update_session_meta("s1", new_meta, model=None)
|
||||
|
||||
row = db.get_session("s1")
|
||||
stored = json.loads(row["model_config"])
|
||||
assert stored["cwd"] == "/new/path"
|
||||
assert stored["provider"] == "openai"
|
||||
|
||||
|
||||
|
||||
def test_uses_execute_write_not_private_api(self, tmp_path):
|
||||
"""update_session_meta must route through _execute_write, not _conn directly."""
|
||||
db = _tmp_db(tmp_path)
|
||||
db.create_session("s4", source="acp")
|
||||
|
||||
call_count = [0]
|
||||
original = db._execute_write
|
||||
|
||||
def patched(fn):
|
||||
call_count[0] += 1
|
||||
return original(fn)
|
||||
|
||||
db._execute_write = patched
|
||||
db.update_session_meta("s4", json.dumps({"cwd": "."}), model="m")
|
||||
|
||||
assert call_count[0] >= 1, (
|
||||
"update_session_meta must call _execute_write at least once"
|
||||
)
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AST check: session.py must not access db._lock or db._conn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNoPrviateDBAccess:
|
||||
"""_persist() in session.py must not access db._lock or db._conn."""
|
||||
|
||||
def test_no_db_private_lock_access(self):
|
||||
with open("acp_adapter/session.py", encoding="utf-8") as f:
|
||||
source = f.read()
|
||||
|
||||
tree = ast.parse(source)
|
||||
|
||||
violations = []
|
||||
for node in ast.walk(tree):
|
||||
# Looking for: db._lock or db._conn
|
||||
if isinstance(node, ast.Attribute):
|
||||
if isinstance(node.value, ast.Name) and node.value.id == "db":
|
||||
if node.attr in ("_lock", "_conn"):
|
||||
violations.append(
|
||||
f"db.{node.attr} at line {node.lineno}"
|
||||
)
|
||||
|
||||
assert violations == [], (
|
||||
"session.py accesses private SessionDB internals: "
|
||||
+ ", ".join(violations)
|
||||
+ " — use db.update_session_meta() instead"
|
||||
)
|
||||
|
||||
def test_persist_calls_update_session_meta(self):
|
||||
"""AST check: _persist must call db.update_session_meta()."""
|
||||
with open("acp_adapter/session.py", encoding="utf-8") as f:
|
||||
tree = ast.parse(f.read())
|
||||
|
||||
found = False
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "_persist":
|
||||
for child in ast.walk(node):
|
||||
if isinstance(child, ast.Call):
|
||||
func = child.func
|
||||
if isinstance(func, ast.Attribute):
|
||||
if func.attr == "update_session_meta":
|
||||
found = True
|
||||
break
|
||||
break
|
||||
|
||||
assert found, (
|
||||
"_persist() must call db.update_session_meta() "
|
||||
"instead of db._conn.execute() directly"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: _persist round-trip via SessionManager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPersistRoundTrip:
|
||||
"""End-to-end: save a session and verify DB state is correct."""
|
||||
|
||||
def test_cwd_persisted_via_update_session_meta(self, tmp_path):
|
||||
db = _tmp_db(tmp_path)
|
||||
manager = SessionManager(agent_factory=_mock_agent, db=db)
|
||||
|
||||
state = manager.create_session(cwd="/original")
|
||||
assert db.get_session(state.session_id) is not None
|
||||
|
||||
# Simulate cwd change and save
|
||||
state.cwd = "/updated"
|
||||
manager.save_session(state.session_id)
|
||||
|
||||
row = db.get_session(state.session_id)
|
||||
mc = json.loads(row["model_config"])
|
||||
assert mc["cwd"] == "/updated"
|
||||
|
||||
def test_model_persisted_via_update_session_meta(self, tmp_path):
|
||||
db = _tmp_db(tmp_path)
|
||||
manager = SessionManager(agent_factory=_mock_agent, db=db)
|
||||
|
||||
state = manager.create_session()
|
||||
state.model = "new-model-xyz"
|
||||
manager.save_session(state.session_id)
|
||||
|
||||
row = db.get_session(state.session_id)
|
||||
assert row["model"] == "new-model-xyz"
|
||||
|
||||
def test_existing_model_not_cleared_on_save(self, tmp_path):
|
||||
"""If state.model is empty, the DB model column must not be overwritten."""
|
||||
db = _tmp_db(tmp_path)
|
||||
manager = SessionManager(agent_factory=_mock_agent, db=db)
|
||||
|
||||
state = manager.create_session()
|
||||
# Manually set a model in DB
|
||||
db.update_session_meta(state.session_id, json.dumps({"cwd": "."}), model="stored-model")
|
||||
|
||||
# Now save with empty model
|
||||
state.model = ""
|
||||
manager.save_session(state.session_id)
|
||||
|
||||
row = db.get_session(state.session_id)
|
||||
assert row["model"] == "stored-model", (
|
||||
"COALESCE must preserve the existing model when new value is NULL"
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Tests for ACP session-provenance derivation (issue #33617).
|
||||
|
||||
Exercises acp_adapter.provenance against a real SessionDB — no mocks — covering
|
||||
the acceptance-criteria matrix: root session, compression-split continuation,
|
||||
multi-depth chains, rotation flagging, and graceful handling of unknown ids.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from acp_adapter.provenance import build_session_provenance, session_provenance_meta
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
d = SessionDB(db_path=tmp_path / "state.db")
|
||||
yield d
|
||||
|
||||
|
||||
def _mk(db, sid, parent=None):
|
||||
db.create_session(session_id=sid, source="acp", parent_session_id=parent)
|
||||
|
||||
|
||||
def test_root_session_no_compression(db):
|
||||
_mk(db, "root1")
|
||||
prov = build_session_provenance(db, "acp-1", "root1")
|
||||
assert prov["acpSessionId"] == "acp-1"
|
||||
assert prov["currentHermesSessionId"] == "root1"
|
||||
assert prov["rootHermesSessionId"] == "root1"
|
||||
assert prov["parentHermesSessionId"] is None
|
||||
assert prov["sessionKind"] == "root"
|
||||
assert prov["compressionDepth"] == 0
|
||||
assert "reason" not in prov # no rotation signalled
|
||||
|
||||
|
||||
def test_compression_split_continuation(db):
|
||||
# Parent ended with compression, child created afterwards.
|
||||
_mk(db, "old")
|
||||
db.end_session("old", "compression")
|
||||
time.sleep(0.001)
|
||||
_mk(db, "new", parent="old")
|
||||
|
||||
prov = build_session_provenance(
|
||||
db, "acp-1", "new", previous_hermes_session_id="old"
|
||||
)
|
||||
assert prov["sessionKind"] == "continuation"
|
||||
assert prov["parentHermesSessionId"] == "old"
|
||||
assert prov["rootHermesSessionId"] == "old"
|
||||
assert prov["compressionDepth"] == 1
|
||||
assert prov["previousHermesSessionId"] == "old"
|
||||
# Head rotated this turn → reason/creatorKind flagged.
|
||||
assert prov["reason"] == "compression"
|
||||
assert prov["creatorKind"] == "compression"
|
||||
|
||||
|
||||
|
||||
|
||||
def test_non_compression_parent_is_root_not_continuation(db):
|
||||
# A child with a parent that did NOT end via compression (e.g. delegate
|
||||
# or branch child) must not be reported as a compression continuation.
|
||||
_mk(db, "p")
|
||||
_mk(db, "c", parent="p") # parent still live, no end_reason
|
||||
prov = build_session_provenance(db, "acp-1", "c")
|
||||
assert prov["sessionKind"] == "root"
|
||||
assert prov["compressionDepth"] == 0
|
||||
assert prov["rootHermesSessionId"] == "p" # lineage root still walked
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_meta_wrapper_shape(db):
|
||||
_mk(db, "root1")
|
||||
meta = session_provenance_meta(db, "acp-1", "root1")
|
||||
assert set(meta.keys()) == {"hermes"}
|
||||
assert "sessionProvenance" in meta["hermes"]
|
||||
assert meta["hermes"]["sessionProvenance"]["currentHermesSessionId"] == "root1"
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Tests for acp_adapter.tools — tool kind mapping and ACP content building."""
|
||||
|
||||
|
||||
from acp_adapter.edit_approval import EditProposal
|
||||
from acp_adapter.tools import (
|
||||
TOOL_KIND_MAP,
|
||||
build_tool_complete,
|
||||
build_tool_start,
|
||||
build_tool_title,
|
||||
extract_locations,
|
||||
get_tool_kind,
|
||||
make_tool_call_id,
|
||||
)
|
||||
from acp.schema import (
|
||||
FileEditToolCallContent,
|
||||
ContentToolCallContent,
|
||||
ToolCallLocation,
|
||||
ToolCallStart,
|
||||
ToolCallProgress,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TOOL_KIND_MAP coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
COMMON_HERMES_TOOLS = ["read_file", "search_files", "terminal", "patch", "write_file", "process"]
|
||||
|
||||
|
||||
class TestToolKindMap:
|
||||
def test_all_hermes_tools_have_kind(self):
|
||||
"""Every common hermes tool should appear in TOOL_KIND_MAP."""
|
||||
for tool in COMMON_HERMES_TOOLS:
|
||||
assert tool in TOOL_KIND_MAP, f"{tool} missing from TOOL_KIND_MAP"
|
||||
|
||||
def test_tool_kind_read_file(self):
|
||||
assert get_tool_kind("read_file") == "read"
|
||||
|
||||
def test_tool_kind_terminal(self):
|
||||
assert get_tool_kind("terminal") == "execute"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_unknown_tool_returns_other_kind(self):
|
||||
assert get_tool_kind("nonexistent_tool_xyz") == "other"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# make_tool_call_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMakeToolCallId:
|
||||
def test_returns_string(self):
|
||||
tc_id = make_tool_call_id()
|
||||
assert isinstance(tc_id, str)
|
||||
|
||||
def test_starts_with_tc_prefix(self):
|
||||
tc_id = make_tool_call_id()
|
||||
assert tc_id.startswith("tc-")
|
||||
|
||||
def test_ids_are_unique(self):
|
||||
ids = {make_tool_call_id() for _ in range(100)}
|
||||
assert len(ids) == 100
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_tool_title
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildToolTitle:
|
||||
def test_terminal_title_includes_command(self):
|
||||
title = build_tool_title("terminal", {"command": "ls -la /tmp"})
|
||||
assert "ls -la /tmp" in title
|
||||
|
||||
def test_terminal_title_truncates_long_command(self):
|
||||
long_cmd = "x" * 200
|
||||
title = build_tool_title("terminal", {"command": long_cmd})
|
||||
assert len(title) < 120
|
||||
assert "..." in title
|
||||
|
||||
def test_read_file_title(self):
|
||||
title = build_tool_title("read_file", {"path": "/etc/hosts"})
|
||||
assert "/etc/hosts" in title
|
||||
|
||||
|
||||
def test_search_title(self):
|
||||
title = build_tool_title("search_files", {"pattern": "TODO"})
|
||||
assert "TODO" in title
|
||||
|
||||
|
||||
|
||||
|
||||
def test_skill_view_title_includes_skill_name(self):
|
||||
title = build_tool_title("skill_view", {"name": "github-pitfalls"})
|
||||
assert title == "skill view (github-pitfalls)"
|
||||
|
||||
|
||||
def test_execute_code_title_includes_first_code_line(self):
|
||||
title = build_tool_title("execute_code", {"code": "\nfrom hermes_tools import terminal\nprint('done')"})
|
||||
assert title == "python: from hermes_tools import terminal"
|
||||
|
||||
|
||||
def test_unknown_tool_uses_name(self):
|
||||
title = build_tool_title("some_new_tool", {"foo": "bar"})
|
||||
assert title == "some_new_tool"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_tool_start
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildToolStart:
|
||||
def test_build_tool_start_for_patch(self):
|
||||
"""patch start should not duplicate the edit-approval diff."""
|
||||
args = {
|
||||
"path": "src/main.py",
|
||||
"old_string": "print('hello')",
|
||||
"new_string": "print('world')",
|
||||
}
|
||||
result = build_tool_start("tc-1", "patch", args)
|
||||
assert isinstance(result, ToolCallStart)
|
||||
assert result.kind == "edit"
|
||||
assert len(result.content) >= 1
|
||||
item = result.content[0]
|
||||
assert isinstance(item, ContentToolCallContent)
|
||||
assert "Approval prompt shows the diff" in item.content.text
|
||||
assert "src/main.py" in item.content.text
|
||||
|
||||
|
||||
def test_auto_approved_edit_start_shows_diff_content(self):
|
||||
"""Auto-approved edit starts need the diff because no approval card exists."""
|
||||
args = {"path": "/tmp/acp.txt", "old_string": "old", "new_string": "new"}
|
||||
result = build_tool_start(
|
||||
"tc-auto-edit",
|
||||
"patch",
|
||||
args,
|
||||
edit_diff=EditProposal("patch", "/tmp/acp.txt", "old\n", "new\n", args),
|
||||
)
|
||||
|
||||
assert isinstance(result, ToolCallStart)
|
||||
assert result.kind == "edit"
|
||||
assert len(result.content) == 1
|
||||
item = result.content[0]
|
||||
assert isinstance(item, FileEditToolCallContent)
|
||||
assert item.path == "/tmp/acp.txt"
|
||||
assert item.old_text == "old\n"
|
||||
assert item.new_text == "new\n"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_build_tool_start_for_browser_navigate(self):
|
||||
"""browser_navigate should emit a polished start event."""
|
||||
args = {"url": "https://x.com"}
|
||||
result = build_tool_start("tc-browser-start", "browser_navigate", args)
|
||||
assert isinstance(result, ToolCallStart)
|
||||
assert result.title == "navigate: https://x.com"
|
||||
assert result.kind == "fetch"
|
||||
assert result.content[0].content.text == '{\n "url": "https://x.com"\n}'
|
||||
assert result.raw_input is None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_tool_complete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildToolComplete:
|
||||
def test_build_tool_complete_for_terminal(self):
|
||||
"""Completed terminal call should include output text."""
|
||||
result = build_tool_complete("tc-2", "terminal", "total 42\ndrwxr-xr-x 2 root root 4096 ...")
|
||||
assert isinstance(result, ToolCallProgress)
|
||||
assert result.status == "completed"
|
||||
assert len(result.content) >= 1
|
||||
content_item = result.content[0]
|
||||
assert isinstance(content_item, ContentToolCallContent)
|
||||
assert "total 42" in content_item.content.text
|
||||
assert result.raw_output is None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_build_tool_complete_marks_returncode_nonzero_as_failed(self):
|
||||
result = build_tool_complete("tc-fail", "execute_code", '{"output": "bad", "returncode": 2}')
|
||||
assert result.status == "failed"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_build_tool_complete_for_search_files_formats_matches(self):
|
||||
result = build_tool_complete(
|
||||
"tc-search",
|
||||
"search_files",
|
||||
'{"total_count":2,"matches":[{"path":"README.md","line":3,"content":"TODO: fix this"},{"path":"src/app.py","line":9,"content":"needle"}],"truncated":true}\n\n[Hint: Results truncated. Use offset=12 to see more.]',
|
||||
)
|
||||
text = result.content[0].content.text
|
||||
assert "Search results" in text
|
||||
assert "Found 2 matches" in text
|
||||
assert "README.md:3" in text
|
||||
assert "TODO: fix this" in text
|
||||
assert "Results truncated" in text
|
||||
assert result.raw_output is None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_build_tool_complete_generically_formats_unknown_json_dict_without_raw_output(self):
|
||||
result = build_tool_complete(
|
||||
"tc-recall-search",
|
||||
"memory_archive_search",
|
||||
'{"results":[{"id":"obs-1","status":"active","content":"Recall should render as a readable summary."}],"trust":"lower-trust archive evidence"}',
|
||||
)
|
||||
text = result.content[0].content.text
|
||||
assert "memory_archive_search result" in text
|
||||
assert "lower-trust archive evidence" in text
|
||||
assert "Recall should render as a readable summary" in text
|
||||
assert "{\"results\"" not in text
|
||||
assert result.raw_output is None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_locations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractLocations:
|
||||
def test_extract_locations_with_path(self):
|
||||
args = {"path": "src/app.py", "offset": 42}
|
||||
locs = extract_locations(args)
|
||||
assert len(locs) == 1
|
||||
assert isinstance(locs[0], ToolCallLocation)
|
||||
assert locs[0].path == "src/app.py"
|
||||
assert locs[0].line == 42
|
||||
|
||||
def test_extract_locations_without_path(self):
|
||||
args = {"command": "echo hi"}
|
||||
locs = extract_locations(args)
|
||||
assert locs == []
|
||||
Reference in New Issue
Block a user