logic-engine / tests /test_ace_mcp_handlers.py
ghostdrive1's picture
Upload folder using huggingface_hub
116524e verified
Raw
History Blame Contribute Delete
18.4 kB
import asyncio
import json
import pytest
from unittest.mock import MagicMock, patch
from ace.integrations.mcp.config import MCPServerConfig
from ace.integrations.mcp.registry import SessionRegistry
from ace.integrations.mcp.handlers import MCPHandlers
from ace.integrations.mcp.models import (
AskRequest,
LearnSampleRequest,
LearnFeedbackRequest,
SkillbookGetRequest,
SkillbookSaveRequest,
SkillbookLoadRequest,
SampleItem,
)
from ace.integrations.mcp.errors import (
ForbiddenInSafeModeError,
SaveLoadDisabledError,
ValidationError,
map_error_to_mcp,
)
from ace.integrations.mcp.errors import TimeoutError as MCPTimeoutError
@pytest.fixture
def config():
return MCPServerConfig(safe_mode=False)
@pytest.fixture
def registry(config):
return SessionRegistry(config)
@pytest.fixture
def handlers(registry, config):
return MCPHandlers(registry, config)
# ── ace.ask ──────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_handle_ask(handlers):
with patch("ace.integrations.mcp.registry.ACELiteLLM") as mock_runner_cls:
runner = MagicMock()
runner.ask.return_value = "answer"
runner.skillbook.skills.return_value = [1, 2, 3]
mock_runner_cls.from_model.return_value = runner
req = AskRequest(session_id="s1", question="q")
resp = await handlers.handle_ask(req)
assert resp.answer == "answer"
assert resp.skill_count == 3
# applied_skill_ids was removed from the response model
assert "applied_skill_ids" not in resp.model_fields
runner.ask.assert_called_once()
@pytest.mark.asyncio
async def test_handle_ask_enforces_prompt_limit(handlers):
handlers.config.max_prompt_chars = 10
req = AskRequest(session_id="s1", question="12345678901")
with pytest.raises(ValidationError):
await handlers.handle_ask(req)
# ── ace.skillbook.get ────────────────────────────────────────────
@pytest.mark.asyncio
async def test_handle_skillbook_get(handlers, registry):
with patch("ace.integrations.mcp.registry.ACELiteLLM") as mock_runner_cls:
runner = MagicMock()
mock_skill = MagicMock()
mock_skill.id = "k1"
mock_skill.insight = "cont"
mock_skill.issue = "cont"
mock_skill.section = "test"
mock_skill.helpful_count = 1
mock_skill.harmful_count = 0
mock_skill.neutral_count = 0
runner.skillbook.skills.return_value = [mock_skill]
runner.skillbook.stats.return_value = {"skills": 1}
mock_runner_cls.from_model.return_value = runner
await registry.get_or_create("s1")
req = SkillbookGetRequest(session_id="s1")
resp = await handlers.handle_skillbook_get(req)
assert len(resp.skills) == 1
assert resp.skills[0].id == "k1"
assert resp.stats["skills"] == 1
@pytest.mark.asyncio
async def test_handle_skillbook_get_uses_skill_type(handlers, registry):
"""When skills are actual Skill dataclass instances, use direct access."""
from ace.core.skillbook import Skill
with patch("ace.integrations.mcp.registry.ACELiteLLM") as mock_runner_cls:
runner = MagicMock()
skill = Skill(
id="s1",
section="context",
keywords=["topic-a"],
issue="do X",
insight="do X",
)
runner.skillbook.skills.return_value = [skill]
runner.skillbook.stats.return_value = {"skills": 1}
mock_runner_cls.from_model.return_value = runner
await registry.get_or_create("s1")
req = SkillbookGetRequest(session_id="s1")
resp = await handlers.handle_skillbook_get(req)
assert resp.skills[0].id == "s1"
assert resp.skills[0].topic == "context"
assert resp.skills[0].content == "do X"
assert resp.skills[0].helpful == 0
assert resp.skills[0].harmful == 0
assert resp.skills[0].neutral == 0
# ── ace.learn.sample ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_handle_learn_sample_safe_mode(handlers):
handlers.config.safe_mode = True
req = LearnSampleRequest(session_id="s1", samples=[SampleItem(question="q")])
with pytest.raises(ForbiddenInSafeModeError):
await handlers.handle_learn_sample(req)
@pytest.mark.asyncio
async def test_handle_learn_sample_enforces_runtime_sample_limit(handlers):
handlers.config.max_samples_per_call = 1
req = LearnSampleRequest(
session_id="s1",
samples=[SampleItem(question="q1"), SampleItem(question="q2")],
)
with pytest.raises(ValidationError):
await handlers.handle_learn_sample(req)
# ── ace.learn.feedback ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_handle_learn_feedback_uses_trace_learning(handlers):
with patch("ace.integrations.mcp.registry.ACELiteLLM") as mock_runner_cls:
runner = MagicMock()
runner.skillbook.skills.side_effect = [["a"], ["a", "b"]]
runner.learn_from_feedback.return_value = False
runner.learn_from_traces.return_value = []
mock_runner_cls.from_model.return_value = runner
req = LearnFeedbackRequest(
session_id="s1",
question="q",
answer="a",
feedback="good",
)
resp = await handlers.handle_learn_feedback(req)
assert resp.learned is True
assert resp.new_skill_count == 1
runner.learn_from_traces.assert_called_once()
@pytest.mark.asyncio
async def test_handle_learn_feedback_always_reports_learned_true(handlers):
"""learned=True on success even when no new skills are created."""
with patch("ace.integrations.mcp.registry.ACELiteLLM") as mock_runner_cls:
runner = MagicMock()
runner.skillbook.skills.side_effect = [["a"], ["a"]]
runner.learn_from_feedback.return_value = True
mock_runner_cls.from_model.return_value = runner
req = LearnFeedbackRequest(
session_id="s1",
question="q",
answer="a",
feedback="good",
)
resp = await handlers.handle_learn_feedback(req)
assert resp.learned is True
assert resp.new_skill_count == 0
@pytest.mark.asyncio
async def test_handle_learn_feedback_trace_uses_context_not_reasoning(handlers):
"""Fallback trace must map context to 'context', not 'reasoning'."""
with patch("ace.integrations.mcp.registry.ACELiteLLM") as mock_runner_cls:
runner = MagicMock()
runner.skillbook.skills.side_effect = [[], []]
runner.learn_from_feedback.return_value = False
runner.learn_from_traces.return_value = []
mock_runner_cls.from_model.return_value = runner
req = LearnFeedbackRequest(
session_id="s1",
question="q",
answer="a",
feedback="good",
context="some background",
)
await handlers.handle_learn_feedback(req)
trace = runner.learn_from_traces.call_args[0][0][0]
assert trace["context"] == "some background"
assert "reasoning" not in trace
# ── ace.skillbook.save/load ──────────────────────────────────────
@pytest.mark.asyncio
async def test_handle_save_safe_mode(handlers, registry):
handlers.config.safe_mode = True
with patch("ace.integrations.mcp.registry.ACELiteLLM"):
await registry.get_or_create("s1")
req = SkillbookSaveRequest(session_id="s1", path="/tmp/some")
with pytest.raises(ForbiddenInSafeModeError):
await handlers.handle_skillbook_save(req)
@pytest.mark.asyncio
async def test_handle_load_safe_mode(handlers):
handlers.config.safe_mode = True
req = SkillbookLoadRequest(session_id="s1", path="/tmp/some")
with pytest.raises(ForbiddenInSafeModeError):
await handlers.handle_skillbook_load(req)
@pytest.mark.asyncio
async def test_handle_save_load_disabled(handlers, registry):
"""allow_save_load=false with safe_mode=false raises SaveLoadDisabledError."""
handlers.config.safe_mode = False
handlers.config.allow_save_load = False
with patch("ace.integrations.mcp.registry.ACELiteLLM"):
await registry.get_or_create("s1")
with pytest.raises(SaveLoadDisabledError):
await handlers.handle_skillbook_save(
SkillbookSaveRequest(session_id="s1", path="/tmp/f")
)
with pytest.raises(SaveLoadDisabledError):
await handlers.handle_skillbook_load(
SkillbookLoadRequest(session_id="s1", path="/tmp/f")
)
@pytest.mark.asyncio
async def test_handle_skillbook_save_rejects_path_outside_root(handlers, registry):
handlers.config.skillbook_root = "/tmp/ace-root"
with patch("ace.integrations.mcp.registry.ACELiteLLM"):
await registry.get_or_create("s1")
req = SkillbookSaveRequest(session_id="s1", path="/tmp/not-allowed/file.json")
with pytest.raises(ValidationError):
await handlers.handle_skillbook_save(req)
@pytest.mark.asyncio
async def test_handle_skillbook_load_rejects_path_outside_root(handlers):
handlers.config.skillbook_root = "/tmp/ace-root"
req = SkillbookLoadRequest(session_id="s1", path="/tmp/not-allowed/file.json")
with pytest.raises(ValidationError):
await handlers.handle_skillbook_load(req)
# ── ace.learn.sample success path ────────────────────────────────
@pytest.mark.asyncio
async def test_handle_learn_sample_success(handlers):
"""Success path: learning processes samples and returns counts."""
with patch("ace.integrations.mcp.registry.ACELiteLLM") as mock_runner_cls:
runner = MagicMock()
result_ok = MagicMock(error=None)
runner.learn.return_value = [result_ok, result_ok]
runner.skillbook.skills.side_effect = [["a"], ["a", "b", "c"]]
mock_runner_cls.from_model.return_value = runner
req = LearnSampleRequest(
session_id="s1",
samples=[
SampleItem(question="q1", ground_truth="gt1"),
SampleItem(question="q2", ground_truth="gt2"),
],
)
resp = await handlers.handle_learn_sample(req)
assert resp.processed == 2
assert resp.failed == 0
assert resp.skill_count_before == 1
assert resp.skill_count_after == 3
assert resp.new_skill_count == 2
runner.learn.assert_called_once()
@pytest.mark.asyncio
async def test_handle_learn_sample_partial_failure(handlers):
"""When some samples fail, counts reflect partial success."""
with patch("ace.integrations.mcp.registry.ACELiteLLM") as mock_runner_cls:
runner = MagicMock()
result_ok = MagicMock(error=None)
result_fail = MagicMock(error="provider error")
runner.learn.return_value = [result_ok, result_fail]
runner.skillbook.skills.side_effect = [[], ["s1"]]
mock_runner_cls.from_model.return_value = runner
req = LearnSampleRequest(
session_id="s1",
samples=[
SampleItem(question="q1"),
SampleItem(question="q2"),
],
)
resp = await handlers.handle_learn_sample(req)
assert resp.processed == 1
assert resp.failed == 1
@pytest.mark.asyncio
async def test_handle_learn_sample_timeout(handlers):
"""learn.sample raises MCPTimeoutError when learn() exceeds timeout."""
handlers.config.learn_timeout_seconds = 0 # instant timeout
with patch("ace.integrations.mcp.registry.ACELiteLLM") as mock_runner_cls:
runner = MagicMock()
async def slow_learn(*args, **kwargs):
await asyncio.sleep(10)
runner.learn.side_effect = (
lambda *a, **kw: asyncio.get_event_loop().run_until_complete(slow_learn())
)
runner.skillbook.skills.return_value = []
mock_runner_cls.from_model.return_value = runner
req = LearnSampleRequest(
session_id="s1",
samples=[SampleItem(question="q")],
)
with pytest.raises(MCPTimeoutError):
await handlers.handle_learn_sample(req)
# ── ace.skillbook.save/load success paths ────────────────────────
@pytest.mark.asyncio
async def test_handle_skillbook_save_success(handlers, registry):
"""Success path: save returns the resolved path and skill count."""
with patch("ace.integrations.mcp.registry.ACELiteLLM") as mock_runner_cls:
runner = MagicMock()
runner.save.return_value = None
runner.skillbook.skills.return_value = ["s1", "s2"]
mock_runner_cls.from_model.return_value = runner
await registry.get_or_create("s1")
req = SkillbookSaveRequest(session_id="s1", path="/tmp/test.json")
resp = await handlers.handle_skillbook_save(req)
assert resp.saved_skill_count == 2
assert resp.session_id == "s1"
runner.save.assert_called_once()
@pytest.mark.asyncio
async def test_handle_skillbook_load_success(handlers):
"""Success path: load returns the resolved path and new skill count."""
with patch("ace.integrations.mcp.registry.ACELiteLLM") as mock_runner_cls:
runner = MagicMock()
runner.load.return_value = None
runner.skillbook.skills.return_value = ["s1", "s2", "s3"]
mock_runner_cls.from_model.return_value = runner
req = SkillbookLoadRequest(session_id="s1", path="/tmp/test.json")
resp = await handlers.handle_skillbook_load(req)
assert resp.skill_count == 3
assert resp.session_id == "s1"
runner.load.assert_called_once()
# ── ace.skillbook.save/load uses resolved path ───────────────────
@pytest.mark.asyncio
async def test_handle_save_uses_resolved_path(handlers, registry):
"""save() receives the resolved path, not the raw user input."""
with patch("ace.integrations.mcp.registry.ACELiteLLM") as mock_runner_cls:
runner = MagicMock()
runner.save.return_value = None
runner.skillbook.skills.return_value = []
mock_runner_cls.from_model.return_value = runner
await registry.get_or_create("s1")
# Path with .. that resolves to /tmp/test.json
req = SkillbookSaveRequest(session_id="s1", path="/tmp/sub/../test.json")
resp = await handlers.handle_skillbook_save(req)
# The runner should receive the resolved path
called_path = runner.save.call_args[0][0]
assert ".." not in called_path
assert resp.path == called_path
# ── error-to-MCP mapping ────────────────────────────────────────
@pytest.mark.asyncio
async def test_handle_call_tool_error_mapping(handlers):
"""handle_call_tool maps domain errors to MCP error envelopes."""
from ace.integrations.mcp.adapters import register_tools
try:
from mcp.server import Server
from mcp import types
except ImportError:
pytest.skip("mcp not installed")
server = Server("test")
register_tools(server, handlers)
# Call a tool that will fail (session not found for skillbook.get)
req = SkillbookGetRequest(session_id="nonexistent")
# Use the handlers directly β€” the adapter error mapping is tested via map_error_to_mcp
from ace.integrations.mcp.errors import SessionNotFoundError
err = SessionNotFoundError("nonexistent")
mapped = map_error_to_mcp(err)
assert mapped["code"] == "ACE_MCP_SESSION_NOT_FOUND"
assert "nonexistent" in mapped["message"]
assert mapped["details"]["session_id"] == "nonexistent"
def test_map_error_to_mcp_unknown_error():
"""Unknown exceptions map to ACE_MCP_INTERNAL_ERROR."""
err = RuntimeError("boom")
mapped = map_error_to_mcp(err)
assert mapped["code"] == "ACE_MCP_INTERNAL_ERROR"
assert "boom" in mapped["message"]
assert mapped["details"]["type"] == "RuntimeError"
# ── sample indexing uses 0-based ─────────────────────────────────
@pytest.mark.asyncio
async def test_handle_learn_sample_prompt_limit_uses_zero_index(handlers):
"""Error message for oversized samples uses 0-based index."""
handlers.config.max_prompt_chars = 5
req = LearnSampleRequest(
session_id="s1",
samples=[
SampleItem(question="ok"), # fits
SampleItem(question="toolong"), # exceeds limit
],
)
with pytest.raises(ValidationError, match=r"samples\[1\]"):
await handlers.handle_learn_sample(req)
# ── ground_truth included in feedback prompt limit ───────────────
@pytest.mark.asyncio
async def test_handle_learn_feedback_prompt_limit_includes_ground_truth(handlers):
"""ground_truth contributes to the prompt limit check."""
handlers.config.max_prompt_chars = 20
req = LearnFeedbackRequest(
session_id="s1",
question="q",
answer="a",
feedback="f",
context="c",
ground_truth="x" * 20, # pushes total over 20
)
with pytest.raises(ValidationError):
await handlers.handle_learn_feedback(req)