forked from Zakaria/hermes-agent
Hermes-agent
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""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
|
||||
|
||||
|
||||
|
||||
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_callback_also_thread_local(self):
|
||||
"""Same protection applies to the sudo password callback."""
|
||||
from tools.terminal_tool import (
|
||||
set_sudo_password_callback,
|
||||
_get_sudo_password_callback,
|
||||
)
|
||||
|
||||
cb_main = lambda: "main-password" # noqa: E731
|
||||
set_sudo_password_callback(cb_main)
|
||||
|
||||
worker_saw = []
|
||||
|
||||
def worker():
|
||||
worker_saw.append(_get_sudo_password_callback())
|
||||
|
||||
t = threading.Thread(target=worker)
|
||||
t.start()
|
||||
t.join()
|
||||
|
||||
assert worker_saw == [None]
|
||||
assert _get_sudo_password_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"
|
||||
|
||||
def test_sudo_password_cache_isolated_across_acp_sessions_on_same_pool_thread(self):
|
||||
"""ACP's ThreadPoolExecutor reuses threads. Two ACP sessions that land
|
||||
on the same reused thread must not share the interactive sudo password
|
||||
cache. The fix wraps each session in contextvars.copy_context() and
|
||||
binds HERMES_SESSION_KEY per session, so the cache scope key differs
|
||||
across sessions even when the underlying thread is identical.
|
||||
"""
|
||||
import contextvars
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from gateway.session_context import (
|
||||
clear_session_vars,
|
||||
set_session_vars,
|
||||
)
|
||||
from tools.terminal_tool import (
|
||||
_get_cached_sudo_password,
|
||||
_reset_cached_sudo_passwords,
|
||||
_set_cached_sudo_password,
|
||||
)
|
||||
|
||||
_reset_cached_sudo_passwords()
|
||||
executor = ThreadPoolExecutor(max_workers=1) # force thread reuse
|
||||
|
||||
runs: list[tuple[str, str, str]] = [] # (session_id, before, after)
|
||||
|
||||
def _simulate_acp_session(session_id: str, write_password: str) -> None:
|
||||
tokens = set_session_vars(session_key=session_id)
|
||||
try:
|
||||
observed_before = _get_cached_sudo_password()
|
||||
_set_cached_sudo_password(write_password)
|
||||
observed_after = _get_cached_sudo_password()
|
||||
runs.append((session_id, observed_before, observed_after))
|
||||
finally:
|
||||
clear_session_vars(tokens)
|
||||
|
||||
def _run_in_fresh_context(session_id: str, pw: str) -> str:
|
||||
ctx = contextvars.copy_context()
|
||||
ctx.run(_simulate_acp_session, session_id, pw)
|
||||
return session_id
|
||||
|
||||
try:
|
||||
executor.submit(_run_in_fresh_context, "acp-session-A", "alpha-secret").result()
|
||||
# Same thread. Without the fix B would see "alpha-secret".
|
||||
executor.submit(_run_in_fresh_context, "acp-session-B", "bravo-secret").result()
|
||||
finally:
|
||||
executor.shutdown(wait=True)
|
||||
_reset_cached_sudo_passwords()
|
||||
|
||||
assert runs[0] == ("acp-session-A", "", "alpha-secret")
|
||||
# Core regression guard: B on the same reused thread must see an empty
|
||||
# cache, not A's password.
|
||||
assert runs[1] == ("acp-session-B", "", "bravo-secret")
|
||||
|
||||
|
||||
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,102 @@
|
||||
"""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
|
||||
|
||||
def test_has_no_provider_when_runtime_has_no_key(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda: {"provider": "openrouter", "api_key": ""},
|
||||
)
|
||||
assert has_provider() is False
|
||||
|
||||
def test_has_no_provider_when_runtime_resolution_fails(self, monkeypatch):
|
||||
def _boom():
|
||||
raise RuntimeError("no provider")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _boom)
|
||||
assert has_provider() is False
|
||||
|
||||
|
||||
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"
|
||||
|
||||
def test_detect_anthropic(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda: {"provider": "anthropic", "api_key": "sk-ant-test"},
|
||||
)
|
||||
assert detect_provider() == "anthropic"
|
||||
|
||||
def test_detect_none_when_no_key(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda: {"provider": "kimi-coding", "api_key": ""},
|
||||
)
|
||||
assert detect_provider() is None
|
||||
|
||||
def test_detect_none_on_resolution_error(self, monkeypatch):
|
||||
def _boom():
|
||||
raise RuntimeError("broken")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _boom)
|
||||
assert detect_provider() is None
|
||||
|
||||
def test_detect_provider_strips_and_lowercases_provider(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"]
|
||||
|
||||
def test_build_auth_methods_returns_terminal_setup_when_unconfigured(self, monkeypatch):
|
||||
monkeypatch.setattr("acp_adapter.auth.detect_provider", lambda: None)
|
||||
|
||||
methods = build_auth_methods()
|
||||
payloads = [method.model_dump(by_alias=True, exclude_none=True) for method in methods]
|
||||
|
||||
assert payloads == [
|
||||
{
|
||||
"args": ["--setup"],
|
||||
"description": (
|
||||
"Open Hermes' interactive model/provider setup in a terminal. "
|
||||
"Use this when Hermes has not been configured on this machine yet."
|
||||
),
|
||||
"id": TERMINAL_SETUP_AUTH_METHOD_ID,
|
||||
"name": "Configure Hermes provider",
|
||||
"type": "terminal",
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,207 @@
|
||||
"""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_write_file_rejection_does_not_mutate_existing_file(tmp_path):
|
||||
target = tmp_path / "sample.txt"
|
||||
target.write_text("before\n", encoding="utf-8")
|
||||
|
||||
set_edit_approval_requester(lambda _proposal: False)
|
||||
|
||||
result = json.loads(
|
||||
handle_function_call(
|
||||
"write_file",
|
||||
{"path": str(target), "content": "after\n"},
|
||||
task_id="acp-edit-reject",
|
||||
)
|
||||
)
|
||||
|
||||
assert "error" in result
|
||||
assert "Edit approval denied" in result["error"]
|
||||
assert target.read_text(encoding="utf-8") == "before\n"
|
||||
|
||||
|
||||
def test_write_file_approval_mutates_and_request_includes_diff(tmp_path):
|
||||
target = tmp_path / "sample.txt"
|
||||
target.write_text("before\n", encoding="utf-8")
|
||||
proposals = []
|
||||
|
||||
def approve(proposal):
|
||||
proposals.append(proposal)
|
||||
return True
|
||||
|
||||
set_edit_approval_requester(approve)
|
||||
|
||||
result = json.loads(
|
||||
handle_function_call(
|
||||
"write_file",
|
||||
{"path": str(target), "content": "after\n"},
|
||||
task_id="acp-edit-approve",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.get("bytes_written") == len("after\n")
|
||||
assert target.read_text(encoding="utf-8") == "after\n"
|
||||
assert len(proposals) == 1
|
||||
proposal = proposals[0]
|
||||
assert proposal.tool_name == "write_file"
|
||||
assert proposal.path == str(target)
|
||||
assert proposal.old_text == "before\n"
|
||||
assert proposal.new_text == "after\n"
|
||||
|
||||
|
||||
def test_write_file_new_file_request_has_empty_old_text(tmp_path):
|
||||
target = tmp_path / "new.txt"
|
||||
proposals = []
|
||||
|
||||
set_edit_approval_requester(lambda proposal: proposals.append(proposal) or True)
|
||||
|
||||
result = json.loads(
|
||||
handle_function_call(
|
||||
"write_file",
|
||||
{"path": str(target), "content": "created\n"},
|
||||
task_id="acp-edit-new-file",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.get("bytes_written") == len("created\n")
|
||||
assert target.read_text(encoding="utf-8") == "created\n"
|
||||
assert proposals[0].old_text is None
|
||||
assert proposals[0].new_text == "created\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_patch_replace_approval_request_includes_full_file_diff(tmp_path):
|
||||
target = tmp_path / "sample.txt"
|
||||
target.write_text("alpha\nbeta\n", encoding="utf-8")
|
||||
proposals = []
|
||||
|
||||
set_edit_approval_requester(lambda proposal: proposals.append(proposal) or True)
|
||||
|
||||
result = json.loads(
|
||||
handle_function_call(
|
||||
"patch",
|
||||
{
|
||||
"mode": "replace",
|
||||
"path": str(target),
|
||||
"old_string": "beta\n",
|
||||
"new_string": "gamma\n",
|
||||
},
|
||||
task_id="acp-patch-approve",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.get("success") is True
|
||||
assert target.read_text(encoding="utf-8") == "alpha\ngamma\n"
|
||||
assert proposals[0].tool_name == "patch"
|
||||
assert proposals[0].old_text == "alpha\nbeta\n"
|
||||
assert proposals[0].new_text == "alpha\ngamma\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,155 @@
|
||||
"""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_version_prints_without_starting_server(monkeypatch, capsys):
|
||||
monkeypatch.setattr(entry, "_setup_logging", lambda: (_ for _ in ()).throw(AssertionError("started server")))
|
||||
|
||||
entry.main(["--version"])
|
||||
|
||||
output = capsys.readouterr().out.strip()
|
||||
assert output
|
||||
assert "Starting hermes-agent ACP adapter" not in output
|
||||
|
||||
|
||||
def test_main_check_prints_ok_without_starting_server(monkeypatch, capsys):
|
||||
monkeypatch.setattr(entry, "_setup_logging", lambda: (_ for _ in ()).throw(AssertionError("started server")))
|
||||
|
||||
entry.main(["--check"])
|
||||
|
||||
assert capsys.readouterr().out.strip() == "Hermes ACP check OK"
|
||||
|
||||
|
||||
def test_main_setup_runs_model_configuration(monkeypatch):
|
||||
calls = {}
|
||||
|
||||
def fake_hermes_main():
|
||||
calls["argv"] = sys.argv[:]
|
||||
|
||||
monkeypatch.setattr("hermes_cli.main.main", fake_hermes_main)
|
||||
# Pretend stdin is not a TTY so the follow-up browser prompt is skipped.
|
||||
# That keeps this test focused on the model-setup wiring; the
|
||||
# browser-prompt path has its own test below.
|
||||
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
|
||||
|
||||
entry.main(["--setup"])
|
||||
|
||||
assert calls["argv"][1:] == ["model"]
|
||||
|
||||
|
||||
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_skips_browser_prompt_on_no(monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.main.main", lambda: None)
|
||||
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
|
||||
monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "")
|
||||
|
||||
called = []
|
||||
monkeypatch.setattr(
|
||||
entry,
|
||||
"_run_setup_browser",
|
||||
lambda assume_yes=False: called.append(assume_yes) or 0,
|
||||
)
|
||||
|
||||
entry.main(["--setup"])
|
||||
|
||||
assert called == []
|
||||
|
||||
|
||||
def test_main_setup_browser_calls_ensure_dependency(monkeypatch):
|
||||
"""`hermes-acp --setup-browser` routes through dep_ensure.ensure_dependency."""
|
||||
calls = []
|
||||
|
||||
def fake_ensure(dep, interactive=True):
|
||||
calls.append((dep, interactive))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("hermes_cli.dep_ensure.ensure_dependency", fake_ensure)
|
||||
|
||||
entry.main(["--setup-browser"])
|
||||
|
||||
assert ("node", True) in calls
|
||||
assert ("browser", True) in calls
|
||||
|
||||
|
||||
def test_main_setup_browser_forwards_yes_flag(monkeypatch):
|
||||
"""--yes suppresses interactive prompts in ensure_dependency."""
|
||||
calls = []
|
||||
|
||||
def fake_ensure(dep, interactive=True):
|
||||
calls.append((dep, interactive))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("hermes_cli.dep_ensure.ensure_dependency", fake_ensure)
|
||||
|
||||
entry.main(["--setup-browser", "--yes"])
|
||||
|
||||
assert ("node", False) in calls
|
||||
assert ("browser", False) in calls
|
||||
|
||||
|
||||
def test_main_setup_browser_stops_on_node_failure(monkeypatch):
|
||||
"""If node install fails, browser install is not attempted."""
|
||||
calls = []
|
||||
|
||||
def fake_ensure(dep, interactive=True):
|
||||
calls.append(dep)
|
||||
return dep != "node" # node 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
|
||||
assert "node" in calls
|
||||
assert "browser" not in calls
|
||||
|
||||
|
||||
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,422 @@
|
||||
"""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_handles_string_args(self, mock_conn, event_loop_fixture):
|
||||
"""If args is a JSON string, it should be parsed."""
|
||||
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)
|
||||
|
||||
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", "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 = {}
|
||||
tool_call_meta = {}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_tool_progress_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
|
||||
|
||||
cb("tool.started", "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 = {}
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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()
|
||||
|
||||
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_none_result_passed_through(self, mock_conn, event_loop_fixture):
|
||||
"""When result is None (e.g. first iteration), None is passed through."""
|
||||
from collections import deque
|
||||
|
||||
tool_call_ids = {"web_search": deque(["tc-aaa"])}
|
||||
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
|
||||
|
||||
cb(1, [{"name": "web_search", "result": None}])
|
||||
|
||||
mock_btc.assert_called_once_with("tc-aaa", "web_search", result=None, function_args=None, snapshot=None)
|
||||
|
||||
def test_step_callback_passes_arguments_and_snapshot(self, mock_conn, event_loop_fixture):
|
||||
from collections import deque
|
||||
|
||||
tool_call_ids = {"write_file": deque(["tc-write"])}
|
||||
tool_call_meta = {"tc-write": {"args": {"path": "fallback.txt"}, "snapshot": "snap"}}
|
||||
loop = event_loop_fixture
|
||||
|
||||
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, \
|
||||
patch("acp_adapter.events.build_tool_complete") as mock_btc:
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.return_value = None
|
||||
mock_rcts.return_value = future
|
||||
|
||||
cb(1, [{"name": "write_file", "result": '{"bytes_written": 23}', "arguments": {"path": "diff-test.txt"}}])
|
||||
|
||||
mock_btc.assert_called_once_with(
|
||||
"tc-write",
|
||||
"write_file",
|
||||
result='{"bytes_written": 23}',
|
||||
function_args={"path": "diff-test.txt"},
|
||||
snapshot="snap",
|
||||
)
|
||||
|
||||
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"]
|
||||
|
||||
def test_todo_plan_update_parses_json_with_trailing_hint(self):
|
||||
result = '{"todos":[{"id":"ship","content":"Ship ACP plan","status":"pending"}]}\n\n[Hint: persisted]'
|
||||
|
||||
update = _build_plan_update_from_todo_result(result)
|
||||
|
||||
assert isinstance(update, AgentPlanUpdate)
|
||||
assert [entry.content for entry in update.entries] == ["Ship ACP plan"]
|
||||
assert [entry.status for entry in update.entries] == ["pending"]
|
||||
|
||||
def test_todo_plan_update_with_empty_todos_clears_plan(self):
|
||||
update = _build_plan_update_from_todo_result('{"todos":[],"summary":{"total":0}}')
|
||||
|
||||
assert isinstance(update, AgentPlanUpdate)
|
||||
assert update.session_update == "plan"
|
||||
assert update.entries == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 in the
|
||||
# same xdist worker (or stdlib mock internals) 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,362 @@
|
||||
"""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
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_tool_results_paired_by_call_id(self, acp_agent, mock_manager):
|
||||
"""The ToolCallUpdate's toolCallId must match the ToolCallStart's."""
|
||||
resp = await acp_agent.new_session(cwd="/tmp")
|
||||
session_id = resp.session_id
|
||||
state = mock_manager.get_session(session_id)
|
||||
|
||||
mock_conn = MagicMock(spec=acp.Client)
|
||||
mock_conn.session_update = AsyncMock()
|
||||
mock_conn.request_permission = AsyncMock()
|
||||
acp_agent._conn = mock_conn
|
||||
|
||||
def mock_run(user_message, conversation_history=None, task_id=None, **kwargs):
|
||||
agent = state.agent
|
||||
# Fire two tool calls
|
||||
if agent.tool_progress_callback:
|
||||
agent.tool_progress_callback("tool.started", "read_file", "read: /etc/hosts", {"path": "/etc/hosts"})
|
||||
agent.tool_progress_callback("tool.started", "web_search", "web search: test", {"query": "test"})
|
||||
|
||||
if agent.step_callback:
|
||||
agent.step_callback(1, [
|
||||
{"name": "read_file", "result": '{"content": "127.0.0.1 localhost"}'},
|
||||
{"name": "web_search", "result": '{"data": {"web": []}}'},
|
||||
])
|
||||
|
||||
return {"final_response": "Done.", "messages": []}
|
||||
|
||||
state.agent.run_conversation = mock_run
|
||||
|
||||
prompt = [TextContentBlock(type="text", text="test")]
|
||||
await acp_agent.prompt(prompt=prompt, session_id=session_id)
|
||||
|
||||
updates = []
|
||||
for call in mock_conn.session_update.call_args_list:
|
||||
update_arg = call[1].get("update") or call[0][1]
|
||||
updates.append(update_arg)
|
||||
|
||||
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"]
|
||||
|
||||
assert len(starts) == 2, f"Expected 2 starts, got {len(starts)}"
|
||||
assert len(completions) == 2, f"Expected 2 completions, got {len(completions)}"
|
||||
|
||||
# Each completion's toolCallId must match a start's toolCallId
|
||||
start_ids = {s.tool_call_id for s in starts}
|
||||
completion_ids = {c.tool_call_id for c in completions}
|
||||
assert start_ids == completion_ids, (
|
||||
f"IDs must match: starts={start_ids}, completions={completion_ids}"
|
||||
)
|
||||
|
||||
|
||||
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,233 @@
|
||||
"""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,
|
||||
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,
|
||||
approval_callback=cb,
|
||||
)
|
||||
else:
|
||||
result = cb(
|
||||
"rm -rf /",
|
||||
"dangerous command",
|
||||
allow_permanent=allow_permanent,
|
||||
)
|
||||
|
||||
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_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_prompt_path_keeps_session_option_when_permanent_disabled(self):
|
||||
result, kwargs, _, _, _ = _invoke_callback(
|
||||
AllowedOutcome(option_id="allow_session", outcome="selected"),
|
||||
allow_permanent=False,
|
||||
use_prompt_path=True,
|
||||
)
|
||||
|
||||
option_ids = [option.option_id for option in kwargs["options"]]
|
||||
|
||||
assert result == "session"
|
||||
assert option_ids == ["allow_once", "allow_session", "deny", "deny_always"]
|
||||
|
||||
def test_reject_always_outcome_denies_without_changing_policy(self):
|
||||
result, kwargs, _, _, _ = _invoke_callback(
|
||||
AllowedOutcome(option_id="deny_always", outcome="selected"),
|
||||
use_prompt_path=True,
|
||||
)
|
||||
|
||||
deny_always = [option for option in kwargs["options"] if option.option_id == "deny_always"]
|
||||
|
||||
assert result == "deny"
|
||||
assert len(deny_always) == 1
|
||||
assert deny_always[0].kind == "reject_always"
|
||||
|
||||
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_denied_and_unknown_outcomes_deny(self):
|
||||
denied_result, _, _, _, _ = _invoke_callback(DeniedOutcome(outcome="cancelled"))
|
||||
unknown_result, _, _, _, _ = _invoke_callback(
|
||||
AllowedOutcome(option_id="unexpected", outcome="selected"),
|
||||
)
|
||||
|
||||
assert denied_result == "deny"
|
||||
assert unknown_result == "deny"
|
||||
|
||||
def test_timeout_returns_deny_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()
|
||||
|
||||
assert result == "deny"
|
||||
assert scheduled["loop"] is loop
|
||||
assert future.cancel.call_count == 1
|
||||
|
||||
def test_none_response_returns_deny(self):
|
||||
"""When request_permission resolves to None, the callback returns 'deny'."""
|
||||
loop = MagicMock(spec=asyncio.AbstractEventLoop)
|
||||
request_permission = AsyncMock(name="request_permission")
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.return_value = None
|
||||
|
||||
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=1.0)
|
||||
result = cb("echo hi", "demo")
|
||||
|
||||
scheduled["coro"].close()
|
||||
|
||||
assert result == "deny"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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,210 @@
|
||||
"""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_real_method_not_found() -> None:
|
||||
f = _BenignProbeMethodFilter()
|
||||
exc = _bake_tb(RequestError.method_not_found("session/custom"))
|
||||
record = _make_record("Background task failed", exc)
|
||||
assert f.filter(record) is True
|
||||
|
||||
|
||||
def test_filter_allows_non_request_error() -> None:
|
||||
f = _BenignProbeMethodFilter()
|
||||
exc = _bake_tb(RuntimeError("boom"))
|
||||
record = _make_record("Background task failed", exc)
|
||||
assert f.filter(record) is True
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_filter_allows_request_error_with_different_code() -> None:
|
||||
f = _BenignProbeMethodFilter()
|
||||
exc = _bake_tb(RequestError.invalid_params({"method": "ping"}))
|
||||
record = _make_record("Background task failed", exc)
|
||||
assert f.filter(record) is True
|
||||
|
||||
|
||||
def test_filter_allows_log_without_exc_info() -> None:
|
||||
f = _BenignProbeMethodFilter()
|
||||
record = _make_record("Background task failed", None)
|
||||
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,90 @@
|
||||
"""Tests for ACP Registry metadata shipped with Hermes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MANIFEST = ROOT / "acp_registry" / "agent.json"
|
||||
ICON = ROOT / "acp_registry" / "icon.svg"
|
||||
FORBIDDEN_MANIFEST_KEYS = {"schema_version", "display_name"}
|
||||
ALLOWED_DISTRIBUTIONS = {"binary", "npx", "uvx"}
|
||||
|
||||
|
||||
def _manifest() -> dict:
|
||||
return json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _pyproject_version() -> str:
|
||||
data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
return data["project"]["version"]
|
||||
|
||||
|
||||
def test_agent_json_matches_official_registry_required_fields():
|
||||
data = _manifest()
|
||||
|
||||
assert FORBIDDEN_MANIFEST_KEYS.isdisjoint(data)
|
||||
assert data["id"] == "hermes-agent"
|
||||
assert re.fullmatch(r"[a-z][a-z0-9-]*", data["id"])
|
||||
assert data["name"] == "Hermes Agent"
|
||||
assert data["description"]
|
||||
assert data["repository"] == "https://github.com/NousResearch/hermes-agent"
|
||||
assert data["website"].startswith("https://hermes-agent.nousresearch.com/")
|
||||
assert data["authors"] == ["Nous Research"]
|
||||
assert data["license"] == "MIT"
|
||||
assert set(data["distribution"]) <= ALLOWED_DISTRIBUTIONS
|
||||
|
||||
|
||||
def test_agent_json_uses_uvx_distribution_without_local_command_fields():
|
||||
data = _manifest()
|
||||
|
||||
assert set(data["distribution"]) == {"uvx"}
|
||||
uvx = data["distribution"]["uvx"]
|
||||
# Schema allows {package, args, env}; we use {package, args}.
|
||||
assert set(uvx) <= {"package", "args", "env"}
|
||||
assert "package" in uvx
|
||||
assert uvx["package"] == f"hermes-agent[acp]=={data['version']}"
|
||||
assert uvx["args"] == ["hermes-acp"]
|
||||
# Old command-shape fields must not leak back in.
|
||||
assert "type" not in data["distribution"]
|
||||
assert "command" not in data["distribution"]
|
||||
|
||||
|
||||
def test_agent_json_version_matches_pyproject():
|
||||
assert _manifest()["version"] == _pyproject_version()
|
||||
|
||||
|
||||
def test_agent_json_pins_uvx_package_to_pyproject_version():
|
||||
"""The registry CI rejects ``@latest`` and floating pins; the manifest must
|
||||
always reference the exact PyPI version listed in pyproject.toml."""
|
||||
assert _manifest()["distribution"]["uvx"]["package"] == (
|
||||
f"hermes-agent[acp]=={_pyproject_version()}"
|
||||
)
|
||||
|
||||
|
||||
def test_icon_svg_is_16x16_current_color():
|
||||
root = ET.fromstring(ICON.read_text(encoding="utf-8"))
|
||||
|
||||
assert root.attrib["viewBox"] == "0 0 16 16"
|
||||
assert root.attrib["width"] == "16"
|
||||
assert root.attrib["height"] == "16"
|
||||
|
||||
|
||||
def test_icon_svg_has_no_hardcoded_colors_or_gradients():
|
||||
text = ICON.read_text(encoding="utf-8")
|
||||
|
||||
assert "linearGradient" not in text
|
||||
assert "radialGradient" not in text
|
||||
assert "url(#" not in text
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", text)
|
||||
|
||||
root = ET.fromstring(text)
|
||||
for element in root.iter():
|
||||
for attr in ("fill", "stroke"):
|
||||
value = element.attrib.get(attr)
|
||||
if value is not None:
|
||||
assert value in {"currentColor", "none"}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,598 @@
|
||||
"""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_create_session_registers_task_cwd(self, manager, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr("acp_adapter.session._register_task_cwd", lambda task_id, cwd: calls.append((task_id, cwd)))
|
||||
state = manager.create_session(cwd="/tmp/work")
|
||||
assert calls == [(state.session_id, "/tmp/work")]
|
||||
|
||||
|
||||
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_session_ids_are_unique(self, manager):
|
||||
s1 = manager.create_session()
|
||||
s2 = manager.create_session()
|
||||
assert s1.session_id != s2.session_id
|
||||
|
||||
def test_get_session(self, manager):
|
||||
state = manager.create_session()
|
||||
fetched = manager.get_session(state.session_id)
|
||||
assert fetched is state
|
||||
|
||||
def test_get_nonexistent_session_returns_none(self, manager):
|
||||
assert manager.get_session("does-not-exist") is None
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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_translate_acp_cwd_handles_forward_slashes_when_wsl(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_constants._wsl_detected", True)
|
||||
|
||||
assert acp_session._translate_acp_cwd("D:/work/project") == "/mnt/d/work/project"
|
||||
|
||||
def test_translate_acp_cwd_leaves_windows_drive_path_unchanged_off_wsl(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_constants._wsl_detected", False)
|
||||
|
||||
assert acp_session._translate_acp_cwd(r"E:\Projects\AI\paperclip") == r"E:\Projects\AI\paperclip"
|
||||
|
||||
def test_translate_acp_cwd_leaves_posix_path_unchanged_on_wsl(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_constants._wsl_detected", True)
|
||||
|
||||
assert acp_session._translate_acp_cwd("/mnt/e/Projects/AI/paperclip") == "/mnt/e/Projects/AI/paperclip"
|
||||
|
||||
def test_create_session_stores_translated_cwd_on_wsl(self, manager, monkeypatch):
|
||||
monkeypatch.setattr("hermes_constants._wsl_detected", True)
|
||||
|
||||
state = manager.create_session(cwd=r"E:\Projects\AI\paperclip")
|
||||
|
||||
assert state.cwd == "/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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestForkSession:
|
||||
def test_fork_session_deep_copies_history(self, manager):
|
||||
original = manager.create_session()
|
||||
original.history.append({"role": "user", "content": "hello"})
|
||||
original.history.append({"role": "assistant", "content": "hi"})
|
||||
|
||||
forked = manager.fork_session(original.session_id, cwd="/new")
|
||||
assert forked is not None
|
||||
|
||||
# History should be equal in content
|
||||
assert len(forked.history) == 2
|
||||
assert forked.history[0]["content"] == "hello"
|
||||
|
||||
# But a deep copy — mutating one doesn't affect the other
|
||||
forked.history.append({"role": "user", "content": "extra"})
|
||||
assert len(original.history) == 2
|
||||
assert len(forked.history) == 3
|
||||
|
||||
def test_fork_session_has_new_id(self, manager):
|
||||
original = manager.create_session()
|
||||
forked = manager.fork_session(original.session_id)
|
||||
assert forked is not None
|
||||
assert forked.session_id != original.session_id
|
||||
|
||||
def test_fork_nonexistent_returns_none(self, manager):
|
||||
assert manager.fork_session("bogus-id") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list / cleanup / remove
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListAndCleanup:
|
||||
def test_list_sessions_empty(self, manager):
|
||||
assert manager.list_sessions() == []
|
||||
|
||||
def test_list_sessions_returns_created(self, manager):
|
||||
s1 = manager.create_session(cwd="/a")
|
||||
s2 = manager.create_session(cwd="/b")
|
||||
s1.history.append({"role": "user", "content": "hello from a"})
|
||||
s2.history.append({"role": "user", "content": "hello from b"})
|
||||
listing = manager.list_sessions()
|
||||
ids = {s["session_id"] for s in listing}
|
||||
assert s1.session_id in ids
|
||||
assert s2.session_id in ids
|
||||
assert len(listing) == 2
|
||||
|
||||
def test_list_sessions_hides_empty_threads(self, manager):
|
||||
manager.create_session(cwd="/empty")
|
||||
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 messages == [{"role": "user", "content": "original"}]
|
||||
|
||||
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_create_session_includes_registered_mcp_toolsets(self, tmp_path, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_resolve_runtime_provider(requested=None, **kwargs):
|
||||
return {
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": "https://openrouter.example/v1",
|
||||
"api_key": "***",
|
||||
"command": None,
|
||||
"args": [],
|
||||
}
|
||||
|
||||
def fake_agent(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(model=kwargs.get("model"), enabled_toolsets=kwargs.get("enabled_toolsets"))
|
||||
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {
|
||||
"model": {"provider": "openrouter", "default": "test-model"},
|
||||
"mcp_servers": {
|
||||
"olympus": {"command": "python", "enabled": True},
|
||||
"exa": {"url": "https://exa.ai/mcp"},
|
||||
"disabled": {"command": "python", "enabled": False},
|
||||
},
|
||||
})
|
||||
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)
|
||||
manager.create_session(cwd="/work")
|
||||
|
||||
assert captured["enabled_toolsets"] == ["hermes-acp", "mcp-olympus", "mcp-exa"]
|
||||
|
||||
def test_create_session_writes_to_db(self, manager):
|
||||
state = manager.create_session(cwd="/project")
|
||||
db = manager._get_db()
|
||||
assert db is not None
|
||||
row = db.get_session(state.session_id)
|
||||
assert row is not None
|
||||
assert row["source"] == "acp"
|
||||
# cwd stored in model_config JSON
|
||||
mc = json.loads(row["model_config"])
|
||||
assert mc["cwd"] == "/project"
|
||||
|
||||
def test_get_session_restores_from_db(self, manager):
|
||||
"""Simulate process restart: create session, drop from memory, get again."""
|
||||
state = manager.create_session(cwd="/work")
|
||||
state.history.append({"role": "user", "content": "hello"})
|
||||
state.history.append({"role": "assistant", "content": "hi there"})
|
||||
manager.save_session(state.session_id)
|
||||
|
||||
sid = state.session_id
|
||||
|
||||
# Drop from in-memory store (simulates process restart).
|
||||
with manager._lock:
|
||||
del manager._sessions[sid]
|
||||
|
||||
# get_session should transparently restore from DB.
|
||||
restored = manager.get_session(sid)
|
||||
assert restored is not None
|
||||
assert restored.session_id == sid
|
||||
assert restored.cwd == "/work"
|
||||
assert len(restored.history) == 2
|
||||
assert restored.history[0]["content"] == "hello"
|
||||
assert restored.history[1]["content"] == "hi there"
|
||||
# Agent should have been recreated.
|
||||
assert restored.agent is not None
|
||||
|
||||
def test_save_session_updates_db(self, manager):
|
||||
state = manager.create_session()
|
||||
state.history.append({"role": "user", "content": "test"})
|
||||
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]["content"] == "test"
|
||||
|
||||
def test_remove_session_deletes_from_db(self, manager):
|
||||
state = manager.create_session()
|
||||
db = manager._get_db()
|
||||
assert db.get_session(state.session_id) is not None
|
||||
manager.remove_session(state.session_id)
|
||||
assert db.get_session(state.session_id) is None
|
||||
|
||||
def test_cleanup_removes_all_from_db(self, manager):
|
||||
s1 = manager.create_session()
|
||||
s2 = manager.create_session()
|
||||
db = manager._get_db()
|
||||
assert db.get_session(s1.session_id) is not None
|
||||
assert db.get_session(s2.session_id) is not None
|
||||
manager.cleanup()
|
||||
assert db.get_session(s1.session_id) is None
|
||||
assert db.get_session(s2.session_id) is None
|
||||
|
||||
def test_list_sessions_includes_db_only(self, manager):
|
||||
"""Sessions only in DB (not in memory) appear in list_sessions."""
|
||||
state = manager.create_session(cwd="/db-only")
|
||||
state.history.append({"role": "user", "content": "database only thread"})
|
||||
manager.save_session(state.session_id)
|
||||
sid = state.session_id
|
||||
|
||||
# Drop from memory.
|
||||
with manager._lock:
|
||||
del manager._sessions[sid]
|
||||
|
||||
listing = manager.list_sessions()
|
||||
ids = {s["session_id"] for s in listing}
|
||||
assert sid in ids
|
||||
|
||||
def test_list_sessions_filters_by_cwd(self, manager):
|
||||
keep = manager.create_session(cwd="/keep")
|
||||
drop = manager.create_session(cwd="/drop")
|
||||
keep.history.append({"role": "user", "content": "keep me"})
|
||||
drop.history.append({"role": "user", "content": "drop me"})
|
||||
|
||||
listing = manager.list_sessions(cwd="/keep")
|
||||
ids = {s["session_id"] for s in listing}
|
||||
assert keep.session_id in ids
|
||||
assert drop.session_id not in ids
|
||||
|
||||
def test_list_sessions_matches_windows_and_wsl_paths(self, manager):
|
||||
state = manager.create_session(cwd="/mnt/e/Projects/AI/browser-link-3")
|
||||
state.history.append({"role": "user", "content": "same project from WSL"})
|
||||
|
||||
listing = manager.list_sessions(cwd=r"E:\Projects\AI\browser-link-3")
|
||||
ids = {s["session_id"] for s in listing}
|
||||
assert state.session_id in ids
|
||||
|
||||
def test_list_sessions_prefers_title_then_preview(self, manager):
|
||||
state = manager.create_session(cwd="/named")
|
||||
state.history.append({"role": "user", "content": "Investigate broken ACP history in Zed"})
|
||||
manager.save_session(state.session_id)
|
||||
db = manager._get_db()
|
||||
db.set_session_title(state.session_id, "Fix Zed ACP history")
|
||||
|
||||
listing = manager.list_sessions(cwd="/named")
|
||||
assert listing[0]["title"] == "Fix Zed ACP history"
|
||||
|
||||
db.set_session_title(state.session_id, "")
|
||||
listing = manager.list_sessions(cwd="/named")
|
||||
assert listing[0]["title"].startswith("Investigate broken ACP history")
|
||||
|
||||
def test_list_sessions_sorted_by_most_recent_activity(self, manager):
|
||||
older = manager.create_session(cwd="/ordered")
|
||||
older.history.append({"role": "user", "content": "older"})
|
||||
manager.save_session(older.session_id)
|
||||
time.sleep(0.02)
|
||||
newer = manager.create_session(cwd="/ordered")
|
||||
newer.history.append({"role": "user", "content": "newer"})
|
||||
manager.save_session(newer.session_id)
|
||||
|
||||
listing = manager.list_sessions(cwd="/ordered")
|
||||
assert [item["session_id"] for item in listing[:2]] == [newer.session_id, older.session_id]
|
||||
assert listing[0]["updated_at"]
|
||||
assert listing[1]["updated_at"]
|
||||
|
||||
def test_fork_restores_source_from_db(self, manager):
|
||||
"""Forking a session that is only in DB should work."""
|
||||
original = manager.create_session()
|
||||
original.history.append({"role": "user", "content": "context"})
|
||||
manager.save_session(original.session_id)
|
||||
|
||||
# Drop original from memory.
|
||||
with manager._lock:
|
||||
del manager._sessions[original.session_id]
|
||||
|
||||
forked = manager.fork_session(original.session_id, cwd="/fork")
|
||||
assert forked is not None
|
||||
assert len(forked.history) == 1
|
||||
assert forked.history[0]["content"] == "context"
|
||||
assert forked.session_id != original.session_id
|
||||
|
||||
def test_update_cwd_restores_from_db(self, manager):
|
||||
state = manager.create_session(cwd="/old")
|
||||
sid = state.session_id
|
||||
|
||||
with manager._lock:
|
||||
del manager._sessions[sid]
|
||||
|
||||
updated = manager.update_cwd(sid, "/new")
|
||||
assert updated is not None
|
||||
assert updated.cwd == "/new"
|
||||
|
||||
# Should also be persisted in DB.
|
||||
db = manager._get_db()
|
||||
row = db.get_session(sid)
|
||||
mc = json.loads(row["model_config"])
|
||||
assert mc["cwd"] == "/new"
|
||||
|
||||
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_tool_calls_persisted(self, manager):
|
||||
"""Messages with tool_calls should round-trip through the DB."""
|
||||
state = manager.create_session()
|
||||
state.history.append({
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{"id": "tc_1", "type": "function",
|
||||
"function": {"name": "terminal", "arguments": "{}"}}],
|
||||
})
|
||||
state.history.append({
|
||||
"role": "tool",
|
||||
"content": "output here",
|
||||
"tool_call_id": "tc_1",
|
||||
"name": "terminal",
|
||||
})
|
||||
manager.save_session(state.session_id)
|
||||
|
||||
# Drop from memory, restore from DB.
|
||||
with manager._lock:
|
||||
del manager._sessions[state.session_id]
|
||||
|
||||
restored = manager.get_session(state.session_id)
|
||||
assert restored is not None
|
||||
assert len(restored.history) == 2
|
||||
assert restored.history[0].get("tool_calls") is not None
|
||||
assert restored.history[1].get("tool_call_id") == "tc_1"
|
||||
|
||||
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
|
||||
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_restore_preserves_persisted_provider_snapshot(self, tmp_path, monkeypatch):
|
||||
"""Restored ACP sessions should keep their original runtime provider."""
|
||||
runtime_choice = {"provider": "anthropic"}
|
||||
|
||||
def fake_resolve_runtime_provider(requested=None, **kwargs):
|
||||
provider = requested or runtime_choice["provider"]
|
||||
return {
|
||||
"provider": provider,
|
||||
"api_mode": "anthropic_messages" if provider == "anthropic" else "chat_completions",
|
||||
"base_url": f"https://{provider}.example/v1",
|
||||
"api_key": f"{provider}-key",
|
||||
"command": None,
|
||||
"args": [],
|
||||
}
|
||||
|
||||
def fake_agent(**kwargs):
|
||||
return SimpleNamespace(
|
||||
model=kwargs.get("model"),
|
||||
provider=kwargs.get("provider"),
|
||||
base_url=kwargs.get("base_url"),
|
||||
api_mode=kwargs.get("api_mode"),
|
||||
)
|
||||
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {
|
||||
"model": {"provider": runtime_choice["provider"], "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")
|
||||
manager.save_session(state.session_id)
|
||||
|
||||
with manager._lock:
|
||||
del manager._sessions[state.session_id]
|
||||
|
||||
runtime_choice["provider"] = "openrouter"
|
||||
restored = manager.get_session(state.session_id)
|
||||
|
||||
assert restored is not None
|
||||
assert restored.agent.provider == "anthropic"
|
||||
assert restored.agent.base_url == "https://anthropic.example/v1"
|
||||
|
||||
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,201 @@
|
||||
"""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_updates_model_when_provided(self, tmp_path):
|
||||
db = _tmp_db(tmp_path)
|
||||
db.create_session("s2", source="acp", model="gpt-3.5")
|
||||
|
||||
db.update_session_meta("s2", json.dumps({"cwd": "."}), model="gpt-4o")
|
||||
|
||||
row = db.get_session("s2")
|
||||
assert row["model"] == "gpt-4o"
|
||||
|
||||
def test_preserves_existing_model_when_none(self, tmp_path):
|
||||
"""Passing model=None must leave the stored model unchanged (COALESCE)."""
|
||||
db = _tmp_db(tmp_path)
|
||||
db.create_session("s3", source="acp", model="claude-3")
|
||||
|
||||
db.update_session_meta("s3", json.dumps({"cwd": "."}), model=None)
|
||||
|
||||
row = db.get_session("s3")
|
||||
assert row["model"] == "claude-3"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
def test_noop_on_nonexistent_session(self, tmp_path):
|
||||
"""Updating a non-existent session must not raise."""
|
||||
db = _tmp_db(tmp_path)
|
||||
db.update_session_meta("ghost", json.dumps({"cwd": "."}), model=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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,103 @@
|
||||
"""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_multi_depth_chain(db):
|
||||
_mk(db, "s0")
|
||||
db.end_session("s0", "compression")
|
||||
_mk(db, "s1", parent="s0")
|
||||
db.end_session("s1", "compression")
|
||||
_mk(db, "s2", parent="s1")
|
||||
|
||||
prov = build_session_provenance(db, "acp-1", "s2")
|
||||
assert prov["rootHermesSessionId"] == "s0"
|
||||
assert prov["compressionDepth"] == 2
|
||||
assert prov["sessionKind"] == "continuation"
|
||||
|
||||
|
||||
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_no_false_rotation_when_head_unchanged(db):
|
||||
_mk(db, "s")
|
||||
# previous == current → no rotation reason emitted.
|
||||
prov = build_session_provenance(
|
||||
db, "acp-1", "s", previous_hermes_session_id="s"
|
||||
)
|
||||
assert "reason" not in prov
|
||||
assert "creatorKind" not in prov
|
||||
assert prov["previousHermesSessionId"] == "s"
|
||||
|
||||
|
||||
def test_unknown_session_returns_none(db):
|
||||
assert build_session_provenance(db, "acp-1", "does-not-exist") is None
|
||||
assert session_provenance_meta(db, "acp-1", "does-not-exist") is None
|
||||
|
||||
|
||||
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,638 @@
|
||||
"""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_tool_kind_patch(self):
|
||||
assert get_tool_kind("patch") == "edit"
|
||||
|
||||
def test_tool_kind_write_file(self):
|
||||
assert get_tool_kind("write_file") == "edit"
|
||||
|
||||
def test_tool_kind_web_search(self):
|
||||
assert get_tool_kind("web_search") == "fetch"
|
||||
|
||||
def test_tool_kind_execute_code(self):
|
||||
assert get_tool_kind("execute_code") == "execute"
|
||||
|
||||
def test_tool_kind_todo(self):
|
||||
assert get_tool_kind("todo") == "other"
|
||||
|
||||
def test_tool_kind_skill_view(self):
|
||||
assert get_tool_kind("skill_view") == "read"
|
||||
|
||||
def test_tool_kind_browser_navigate(self):
|
||||
assert get_tool_kind("browser_navigate") == "fetch"
|
||||
|
||||
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_patch_title(self):
|
||||
title = build_tool_title("patch", {"path": "main.py", "mode": "replace"})
|
||||
assert "main.py" in title
|
||||
|
||||
def test_search_title(self):
|
||||
title = build_tool_title("search_files", {"pattern": "TODO"})
|
||||
assert "TODO" in title
|
||||
|
||||
def test_web_search_title(self):
|
||||
title = build_tool_title("web_search", {"query": "python asyncio"})
|
||||
assert "python asyncio" 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_skill_view_title_includes_linked_file(self):
|
||||
title = build_tool_title("skill_view", {"name": "github-pitfalls", "file_path": "references/api.md"})
|
||||
assert title == "skill view (github-pitfalls/references/api.md)"
|
||||
|
||||
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_skill_manage_title_includes_action_and_target(self):
|
||||
title = build_tool_title(
|
||||
"skill_manage",
|
||||
{"action": "patch", "name": "hermes-agent-operations", "file_path": "references/acp.md"},
|
||||
)
|
||||
assert title == "skill patch: hermes-agent-operations/references/acp.md"
|
||||
|
||||
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_build_tool_start_for_write_file(self):
|
||||
"""write_file start should not duplicate the edit-approval diff."""
|
||||
args = {"path": "new_file.py", "content": "print('hello')"}
|
||||
result = build_tool_start("tc-w1", "write_file", 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 "new_file.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_terminal(self):
|
||||
"""terminal should produce text content with the command."""
|
||||
args = {"command": "ls -la /tmp"}
|
||||
result = build_tool_start("tc-2", "terminal", args)
|
||||
assert isinstance(result, ToolCallStart)
|
||||
assert result.kind == "execute"
|
||||
assert len(result.content) >= 1
|
||||
content_item = result.content[0]
|
||||
assert isinstance(content_item, ContentToolCallContent)
|
||||
# The wrapped text block should contain the command
|
||||
text = content_item.content.text
|
||||
assert "ls -la /tmp" in text
|
||||
|
||||
def test_build_tool_start_for_read_file(self):
|
||||
"""read_file start should stay compact; completion carries file contents."""
|
||||
args = {"path": "/etc/hosts", "offset": 1, "limit": 50}
|
||||
result = build_tool_start("tc-3", "read_file", args)
|
||||
assert isinstance(result, ToolCallStart)
|
||||
assert result.kind == "read"
|
||||
assert result.content is None
|
||||
assert result.raw_input is None
|
||||
|
||||
def test_build_tool_start_for_web_extract_is_compact(self):
|
||||
"""web_extract start should stay compact; title identifies URLs."""
|
||||
args = {"urls": ["https://example.com/docs"]}
|
||||
result = build_tool_start("tc-web-start", "web_extract", args)
|
||||
assert isinstance(result, ToolCallStart)
|
||||
assert result.title == "extract: https://example.com/docs"
|
||||
assert result.kind == "fetch"
|
||||
assert result.content is None
|
||||
assert result.raw_input is None
|
||||
|
||||
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
|
||||
|
||||
def test_build_tool_start_for_search(self):
|
||||
"""search_files should include pattern in content."""
|
||||
args = {"pattern": "TODO", "target": "content"}
|
||||
result = build_tool_start("tc-4", "search_files", args)
|
||||
assert isinstance(result, ToolCallStart)
|
||||
assert result.kind == "search"
|
||||
assert "TODO" in result.content[0].content.text
|
||||
assert result.raw_input is None
|
||||
|
||||
def test_build_tool_start_for_todo_is_human_readable(self):
|
||||
args = {"todos": [{"id": "one", "content": "Fix ACP rendering", "status": "in_progress"}]}
|
||||
result = build_tool_start("tc-todo", "todo", args)
|
||||
assert result.title == "todo (1 item)"
|
||||
assert "Fix ACP rendering" in result.content[0].content.text
|
||||
assert result.raw_input is None
|
||||
|
||||
def test_build_tool_start_for_skill_view_is_human_readable(self):
|
||||
result = build_tool_start("tc-skill", "skill_view", {"name": "github-pitfalls"})
|
||||
assert result.title == "skill view (github-pitfalls)"
|
||||
assert "github-pitfalls" in result.content[0].content.text
|
||||
assert result.raw_input is None
|
||||
|
||||
def test_build_tool_start_for_execute_code_shows_code_preview(self):
|
||||
result = build_tool_start("tc-code", "execute_code", {"code": "print('hello')"})
|
||||
assert result.kind == "execute"
|
||||
assert result.title == "python: print('hello')"
|
||||
assert "```python" in result.content[0].content.text
|
||||
assert "print('hello')" in result.content[0].content.text
|
||||
assert result.raw_input is None
|
||||
|
||||
def test_build_tool_start_for_skill_manage_patch_shows_diff(self):
|
||||
result = build_tool_start(
|
||||
"tc-skill-manage",
|
||||
"skill_manage",
|
||||
{
|
||||
"action": "patch",
|
||||
"name": "hermes-agent-operations",
|
||||
"file_path": "references/acp.md",
|
||||
"old_string": "old advice",
|
||||
"new_string": "new advice",
|
||||
},
|
||||
)
|
||||
assert result.kind == "edit"
|
||||
assert result.title == "skill patch: hermes-agent-operations/references/acp.md"
|
||||
assert isinstance(result.content[0], FileEditToolCallContent)
|
||||
assert result.content[0].path == "skills/hermes-agent-operations/references/acp.md"
|
||||
assert result.content[0].old_text == "old advice"
|
||||
assert result.content[0].new_text == "new advice"
|
||||
assert result.raw_input is None
|
||||
|
||||
def test_build_tool_start_generic_fallback(self):
|
||||
"""Unknown tools should get a generic text representation."""
|
||||
args = {"foo": "bar", "baz": 42}
|
||||
result = build_tool_start("tc-5", "some_tool", args)
|
||||
assert isinstance(result, ToolCallStart)
|
||||
assert result.kind == "other"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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_for_todo_is_checklist(self):
|
||||
result = build_tool_complete(
|
||||
"tc-todo",
|
||||
"todo",
|
||||
'{"todos":[{"id":"a","content":"Inspect ACP","status":"completed"},{"id":"b","content":"Patch renderers","status":"in_progress"}],"summary":{"total":2,"pending":0,"in_progress":1,"completed":1,"cancelled":0}}',
|
||||
)
|
||||
text = result.content[0].content.text
|
||||
assert "✅ Inspect ACP" in text
|
||||
assert "- 🔄 Patch renderers" in text
|
||||
assert "**Progress:** 1 completed, 1 in progress, 0 pending" in text
|
||||
assert result.raw_output is None
|
||||
|
||||
def test_build_tool_complete_for_skill_view_summarizes_content_without_raw_json(self):
|
||||
result = build_tool_complete(
|
||||
"tc-skill",
|
||||
"skill_view",
|
||||
'{"success":true,"name":"github-pitfalls","description":"GitHub gotchas","content":"# GitHub Pitfalls\\nUse gh carefully.","path":"github/github-pitfalls/SKILL.md"}',
|
||||
)
|
||||
text = result.content[0].content.text
|
||||
assert "**Skill loaded**" in text
|
||||
assert "`github-pitfalls`" in text
|
||||
assert "GitHub gotchas" in text
|
||||
assert "GitHub Pitfalls" in text
|
||||
assert "Use gh carefully" not in text
|
||||
assert "Full skill content is available to the agent" in text
|
||||
assert result.raw_output is None
|
||||
|
||||
def test_build_tool_complete_for_execute_code_formats_output(self):
|
||||
result = build_tool_complete("tc-code", "execute_code", '{"output":"hello\\n","exit_code":0}')
|
||||
text = result.content[0].content.text
|
||||
assert "Exit code: 0" in text
|
||||
assert "hello" in text
|
||||
assert result.raw_output is None
|
||||
|
||||
def test_build_tool_complete_marks_success_false_as_failed(self):
|
||||
result = build_tool_complete("tc-fail", "skill_manage", '{"success": false, "error": "boom"}')
|
||||
assert result.status == "failed"
|
||||
|
||||
def test_build_tool_complete_marks_ok_false_as_failed(self):
|
||||
result = build_tool_complete("tc-fail", "some_tool", '{"ok": false, "error": "boom"}')
|
||||
assert result.status == "failed"
|
||||
|
||||
def test_build_tool_complete_marks_exit_code_nonzero_as_failed(self):
|
||||
result = build_tool_complete("tc-fail", "terminal", '{"output": "bad", "exit_code": 2}')
|
||||
assert result.status == "failed"
|
||||
|
||||
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_keeps_plain_error_text_completed(self):
|
||||
result = build_tool_complete("tc-ok", "terminal", "tests failed: 1 assertion error")
|
||||
assert result.status == "completed"
|
||||
|
||||
def test_build_tool_complete_marks_raised_exception_prefix_as_failed(self):
|
||||
"""The agent's tool executor wraps raised exceptions in a canonical
|
||||
"Error executing tool '<name>': ..." prefix. That prefix is unique to
|
||||
the wrapper and means the tool blew up, so it must surface as failed
|
||||
in Zed regardless of whether the body parses as JSON.
|
||||
"""
|
||||
result = build_tool_complete(
|
||||
"tc-fail-exc",
|
||||
"patch",
|
||||
"Error executing tool 'patch': KeyError: 'foo'",
|
||||
)
|
||||
assert result.status == "failed"
|
||||
|
||||
def test_build_tool_complete_does_not_match_error_word_alone(self):
|
||||
"""Bare 'Error: ...' messages (without the unique 'Error executing
|
||||
tool '<name>':' prefix) must still be reported as completed — they
|
||||
legitimately appear in compiler/linter/test output.
|
||||
"""
|
||||
result = build_tool_complete(
|
||||
"tc-ok-error-word",
|
||||
"terminal",
|
||||
"Error: pytest collected 0 items",
|
||||
)
|
||||
assert result.status == "completed"
|
||||
|
||||
def test_build_tool_complete_marks_structured_polished_tool_error_as_failed(self):
|
||||
result = build_tool_complete("tc-fail", "read_file", '{"error": "File not found"}')
|
||||
assert result.status == "failed"
|
||||
|
||||
def test_build_tool_complete_keeps_json_error_without_failure_flag_completed(self):
|
||||
result = build_tool_complete("tc-ok", "some_tool", '{"error": "timeout while reading optional source"}')
|
||||
assert result.status == "completed"
|
||||
|
||||
def test_build_tool_complete_for_skill_manage_summarizes_without_raw_json(self):
|
||||
result = build_tool_complete(
|
||||
"tc-skill-manage",
|
||||
"skill_manage",
|
||||
'{"success":true,"message":"Patched references/hermes-acp-zed-rendering.md in skill \'hermes-agent-operations\' (1 replacement)."}',
|
||||
function_args={
|
||||
"action": "patch",
|
||||
"name": "hermes-agent-operations",
|
||||
"file_path": "references/hermes-acp-zed-rendering.md",
|
||||
},
|
||||
)
|
||||
text = result.content[0].content.text
|
||||
assert "**✅ Skill updated**" in text
|
||||
assert "`patch`" in text
|
||||
assert "`hermes-agent-operations`" in text
|
||||
assert "references/hermes-acp-zed-rendering.md" in text
|
||||
assert "{\"success\"" not in text
|
||||
assert result.raw_output is None
|
||||
|
||||
def test_build_tool_complete_for_read_file_formats_content(self):
|
||||
result = build_tool_complete(
|
||||
"tc-read",
|
||||
"read_file",
|
||||
'{"content":"1|hello\\n2|world","total_lines":2}',
|
||||
function_args={"path":"README.md","offset":1,"limit":20},
|
||||
)
|
||||
text = result.content[0].content.text
|
||||
assert "Read README.md" in text
|
||||
assert "```\n1|hello\n2|world\n```" in text
|
||||
assert result.raw_output is None
|
||||
|
||||
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_for_process_list_formats_table(self):
|
||||
result = build_tool_complete(
|
||||
"tc-process",
|
||||
"process",
|
||||
'{"processes":[{"session_id":"p1","status":"running","pid":123,"command":"npm run dev"}]}',
|
||||
function_args={"action":"list"},
|
||||
)
|
||||
text = result.content[0].content.text
|
||||
assert "Processes: 1" in text
|
||||
assert "`p1`" in text
|
||||
assert "npm run dev" in text
|
||||
assert result.raw_output is None
|
||||
|
||||
def test_build_tool_complete_for_delegate_task_summarizes_children(self):
|
||||
result = build_tool_complete(
|
||||
"tc-delegate",
|
||||
"delegate_task",
|
||||
'{"results":[{"task_index":0,"status":"completed","summary":"Reviewed ACP rendering.","model":"gpt-5.5","duration_seconds":3.2,"tool_trace":[{"tool":"read_file"}]}],"total_duration_seconds":3.4}',
|
||||
)
|
||||
text = result.content[0].content.text
|
||||
assert "Delegation results: 1 task" in text
|
||||
assert "Reviewed ACP rendering" in text
|
||||
assert "gpt-5.5" in text
|
||||
assert "Tools: read_file" in text
|
||||
assert result.raw_output is None
|
||||
|
||||
def test_build_tool_complete_for_session_search_recent(self):
|
||||
result = build_tool_complete(
|
||||
"tc-session",
|
||||
"session_search",
|
||||
'{"success":true,"mode":"recent","results":[{"session_id":"s1","title":"ACP work","last_active":"2026-05-02","message_count":12,"preview":"Polished tool rendering."}],"count":1}',
|
||||
)
|
||||
text = result.content[0].content.text
|
||||
assert "Recent sessions" in text
|
||||
assert "ACP work" in text
|
||||
assert "Polished tool rendering" in text
|
||||
assert result.raw_output is None
|
||||
|
||||
def test_build_tool_complete_for_memory_avoids_dumping_entries(self):
|
||||
result = build_tool_complete(
|
||||
"tc-memory",
|
||||
"memory",
|
||||
'{"success":true,"target":"user","entries":["private long memory"],"usage":"1% — 19/2000 chars","entry_count":1,"message":"Entry added."}',
|
||||
function_args={"action":"add","target":"user","content":"User likes concise ACP rendering."},
|
||||
)
|
||||
text = result.content[0].content.text
|
||||
assert "Memory add saved" in text
|
||||
assert "User likes concise ACP rendering" in text
|
||||
assert "private long memory" not in text
|
||||
assert result.raw_output is None
|
||||
|
||||
def test_build_tool_complete_for_web_extract_success_stays_compact(self):
|
||||
result = build_tool_complete(
|
||||
"tc-web-extract",
|
||||
"web_extract",
|
||||
'{"results":[{"url":"https://example.com","title":"Example","content":"# Intro\\nThis is extracted content."}]}',
|
||||
)
|
||||
assert result.content is None
|
||||
assert result.raw_output is None
|
||||
|
||||
def test_build_tool_complete_for_web_extract_error_shows_error(self):
|
||||
result = build_tool_complete(
|
||||
"tc-web-extract-error",
|
||||
"web_extract",
|
||||
'{"results":[{"url":"https://example.com","title":"Example","error":"timeout"}]}',
|
||||
)
|
||||
text = result.content[0].content.text
|
||||
assert "Web extract failed" in text
|
||||
assert "https://example.com" in text
|
||||
assert "timeout" 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
|
||||
|
||||
def test_build_tool_complete_generically_formats_unknown_json_list_without_raw_output(self):
|
||||
result = build_tool_complete(
|
||||
"tc-plugin-list",
|
||||
"some_plugin_tool",
|
||||
'[{"name":"alpha","status":"ok"},{"name":"beta","status":"ok"}]',
|
||||
)
|
||||
text = result.content[0].content.text
|
||||
assert "some_plugin_tool: 2 items" in text
|
||||
assert "alpha" in text
|
||||
assert result.raw_output is None
|
||||
|
||||
def test_build_tool_complete_generically_formats_nested_json_without_inline_blob(self):
|
||||
result = build_tool_complete(
|
||||
"tc-recall-stats",
|
||||
"memory_archive_stats",
|
||||
'{"observations_by_status":{"active":12,"rejected":83},"capabilities":["sqlite-fts5-archive","hash-chain-audit"],"audit":{"ok":true,"count":208,"head":"abc123"}}',
|
||||
)
|
||||
text = result.content[0].content.text
|
||||
assert "**observations_by_status:**" in text
|
||||
assert "**active:** 12" in text
|
||||
assert "**rejected:** 83" in text
|
||||
assert "**capabilities:** 2 items" in text
|
||||
assert "sqlite-fts5-archive" in text
|
||||
assert "**audit:**" in text
|
||||
assert "**ok:** True" in text
|
||||
assert "{\"active\"" not in text
|
||||
assert "[\"sqlite" not in text
|
||||
assert result.raw_output is None
|
||||
|
||||
def test_build_tool_complete_for_search_files_files_only_formats_file_list(self):
|
||||
result = build_tool_complete(
|
||||
"tc-search-files",
|
||||
"search_files",
|
||||
'{"total_count":36,"files":["/home/nour/.hermes/config.yaml","/home/nour/.hermes/profiles/recall-test/config.yaml"],"truncated":true}',
|
||||
)
|
||||
text = result.content[0].content.text
|
||||
assert "File search results" in text
|
||||
assert "Found 36 files; showing 2." in text
|
||||
assert "/home/nour/.hermes/config.yaml" in text
|
||||
assert "use offset to page" in text
|
||||
assert "{\"total_count\"" not in text
|
||||
assert result.raw_output is None
|
||||
|
||||
def test_build_tool_complete_truncates_large_output(self):
|
||||
"""Very large outputs should be truncated."""
|
||||
big_output = "x" * 10000
|
||||
result = build_tool_complete("tc-6", "read_file", big_output)
|
||||
assert isinstance(result, ToolCallProgress)
|
||||
display_text = result.content[0].content.text
|
||||
assert len(display_text) < 6000
|
||||
assert "truncated" in display_text
|
||||
|
||||
def test_build_tool_complete_for_patch_summarizes_without_repeating_diff(self):
|
||||
"""Completed patch calls should not duplicate the edit-approval diff."""
|
||||
patch_result = (
|
||||
'{"success": true, "diff": "--- a/README.md\\n+++ b/README.md\\n@@ -1 +1,2 @@\\n old line\\n+new line\\n", '
|
||||
'"files_modified": ["README.md"]}'
|
||||
)
|
||||
result = build_tool_complete("tc-p1", "patch", patch_result)
|
||||
assert isinstance(result, ToolCallProgress)
|
||||
assert len(result.content) == 1
|
||||
item = result.content[0]
|
||||
assert isinstance(item, ContentToolCallContent)
|
||||
assert "✅ patch completed" in item.content.text
|
||||
assert "README.md" in item.content.text
|
||||
|
||||
def test_build_tool_complete_for_patch_falls_back_to_text_when_no_diff(self):
|
||||
result = build_tool_complete("tc-p2", "patch", '{"success": true}')
|
||||
assert isinstance(result, ToolCallProgress)
|
||||
assert isinstance(result.content[0], ContentToolCallContent)
|
||||
|
||||
def test_build_tool_complete_for_write_file_summarizes_without_repeating_diff(self, tmp_path):
|
||||
target = tmp_path / "diff-test.txt"
|
||||
snapshot = type("Snapshot", (), {"paths": [target], "before": {str(target): None}})()
|
||||
target.write_text("hello from hermes\n", encoding="utf-8")
|
||||
|
||||
result = build_tool_complete(
|
||||
"tc-wf1",
|
||||
"write_file",
|
||||
'{"bytes_written": 18, "dirs_created": false}',
|
||||
function_args={"path": str(target), "content": "hello from hermes\n"},
|
||||
snapshot=snapshot,
|
||||
)
|
||||
assert isinstance(result, ToolCallProgress)
|
||||
assert len(result.content) == 1
|
||||
item = result.content[0]
|
||||
assert isinstance(item, ContentToolCallContent)
|
||||
assert "✅ write_file completed" in item.content.text
|
||||
assert "diff-test.txt" in item.content.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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