Restore the ACP editor-integration implementation that was present on the original PR branch but did not actually land in main. Includes: - acp_adapter/ server, session manager, event bridge, auth, permissions, and tool helpers - hermes acp subcommand and hermes-acp entry point - hermes-acp curated toolset - ACP registry manifest, setup guide, and ACP test suite - jupyter-live-kernel data science skill from the original branch Also updates the revived ACP code for current main by: - resolving runtime providers through the modern shared provider router - binding ACP sessions to per-session cwd task overrides - tracking duplicate same-name tool calls with FIFO IDs - restoring terminal approval callbacks after prompts - normalizing supporting docs/skill metadata Validated with tests/acp and the full pytest suite (-n0).
240 lines
8.3 KiB
Python
240 lines
8.3 KiB
Python
"""Tests for acp_adapter.events — callback factories for ACP notifications."""
|
|
|
|
import asyncio
|
|
from concurrent.futures import Future
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
import acp
|
|
from acp.schema import ToolCallStart, ToolCallProgress, AgentThoughtChunk, AgentMessageChunk
|
|
|
|
from acp_adapter.events import (
|
|
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 = {}
|
|
loop = event_loop_fixture
|
|
|
|
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids)
|
|
|
|
# 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("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_handles_string_args(self, mock_conn, event_loop_fixture):
|
|
"""If args is a JSON string, it should be parsed."""
|
|
tool_call_ids = {}
|
|
loop = event_loop_fixture
|
|
|
|
cb = make_tool_progress_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("read_file", "Reading /etc/hosts", '{"path": "/etc/hosts"}')
|
|
|
|
assert "read_file" in tool_call_ids
|
|
|
|
def test_handles_non_dict_args(self, mock_conn, event_loop_fixture):
|
|
"""If args is not a dict, it should be wrapped."""
|
|
tool_call_ids = {}
|
|
loop = event_loop_fixture
|
|
|
|
cb = make_tool_progress_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("terminal", "$ echo hi", None)
|
|
|
|
assert "terminal" in tool_call_ids
|
|
|
|
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 = {}
|
|
loop = event_loop_fixture
|
|
|
|
progress_cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids)
|
|
step_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
|
|
|
|
progress_cb("terminal", "$ ls", {"command": "ls"})
|
|
progress_cb("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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestThinkingCallback:
|
|
def test_emits_thought_chunk(self, mock_conn, event_loop_fixture):
|
|
"""Thinking callback should emit AgentThoughtChunk."""
|
|
loop = event_loop_fixture
|
|
|
|
cb = make_thinking_cb(mock_conn, "session-1", loop)
|
|
|
|
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("Analyzing the code...")
|
|
|
|
mock_rcts.assert_called_once()
|
|
|
|
def test_ignores_empty_text(self, mock_conn, event_loop_fixture):
|
|
"""Empty text should not emit any update."""
|
|
loop = event_loop_fixture
|
|
|
|
cb = make_thinking_cb(mock_conn, "session-1", loop)
|
|
|
|
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
|
cb("")
|
|
|
|
mock_rcts.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_ignores_untracked_tools(self, mock_conn, event_loop_fixture):
|
|
"""Tools not in tool_call_ids should be silently ignored."""
|
|
tool_call_ids = {}
|
|
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:
|
|
cb(1, [{"name": "unknown_tool", "result": "ok"}])
|
|
|
|
mock_rcts.assert_not_called()
|
|
|
|
def test_handles_string_tool_info(self, mock_conn, event_loop_fixture):
|
|
"""Tool info as a string (just the name) should work."""
|
|
tool_call_ids = {"read_file": "tc-def456"}
|
|
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(2, ["read_file"])
|
|
|
|
assert "read_file" not in tool_call_ids
|
|
mock_rcts.assert_called_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Message callback
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMessageCallback:
|
|
def test_emits_agent_message_chunk(self, mock_conn, event_loop_fixture):
|
|
"""Message callback should emit AgentMessageChunk."""
|
|
loop = event_loop_fixture
|
|
|
|
cb = make_message_cb(mock_conn, "session-1", loop)
|
|
|
|
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("Here is your answer.")
|
|
|
|
mock_rcts.assert_called_once()
|
|
|
|
def test_ignores_empty_message(self, mock_conn, event_loop_fixture):
|
|
"""Empty text should not emit any update."""
|
|
loop = event_loop_fixture
|
|
|
|
cb = make_message_cb(mock_conn, "session-1", loop)
|
|
|
|
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
|
cb("")
|
|
|
|
mock_rcts.assert_not_called()
|