Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
419 lines
14 KiB
Python
419 lines
14 KiB
Python
"""Tests for tools/delegation.py (async delegation model).
|
|
|
|
The delegation tool now returns immediately with a task_id and runs the
|
|
A2A request in the background. Tests verify:
|
|
1. Immediate return with task_id
|
|
2. Background task completion
|
|
3. check_delegation_status retrieval
|
|
4. Error handling (RBAC, discovery, network)
|
|
"""
|
|
|
|
import asyncio
|
|
import importlib.util
|
|
import os
|
|
import sys
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_mock_client(
|
|
discover_status=200,
|
|
discover_payload=None,
|
|
discover_exc=None,
|
|
a2a_status=200,
|
|
a2a_payload=None,
|
|
):
|
|
"""Return (mock_client, mock_client_class) for patching httpx.AsyncClient."""
|
|
if discover_payload is None:
|
|
discover_payload = {"url": "http://peer:8000"}
|
|
if a2a_payload is None:
|
|
a2a_payload = {
|
|
"result": {
|
|
"parts": [{"kind": "text", "text": "done"}],
|
|
"artifacts": [],
|
|
}
|
|
}
|
|
|
|
mock_resp_discover = MagicMock()
|
|
mock_resp_discover.status_code = discover_status
|
|
mock_resp_discover.json.return_value = discover_payload
|
|
|
|
mock_resp_a2a = MagicMock()
|
|
mock_resp_a2a.status_code = a2a_status
|
|
mock_resp_a2a.json.return_value = a2a_payload
|
|
|
|
mock_client = AsyncMock()
|
|
if discover_exc:
|
|
mock_client.get = AsyncMock(side_effect=discover_exc)
|
|
else:
|
|
mock_client.get = AsyncMock(return_value=mock_resp_discover)
|
|
mock_client.post = AsyncMock(return_value=mock_resp_a2a)
|
|
|
|
mock_cls = MagicMock()
|
|
mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
return mock_client, mock_cls
|
|
|
|
|
|
@pytest.fixture
|
|
def delegation_mocks(monkeypatch):
|
|
"""Load the real delegation module with mocked dependencies."""
|
|
mock_audit = MagicMock()
|
|
mock_audit.check_permission = MagicMock(return_value=True)
|
|
mock_audit.get_workspace_roles = MagicMock(return_value=(["operator"], {}))
|
|
mock_audit.log_event = MagicMock()
|
|
|
|
mock_span = MagicMock()
|
|
mock_span.set_attribute = MagicMock()
|
|
mock_span.record_exception = MagicMock()
|
|
mock_span.__enter__ = MagicMock(return_value=mock_span)
|
|
mock_span.__exit__ = MagicMock(return_value=False)
|
|
|
|
mock_tracer = MagicMock()
|
|
mock_tracer.start_as_current_span = MagicMock(return_value=mock_span)
|
|
|
|
mock_telemetry = MagicMock()
|
|
mock_telemetry.get_tracer = MagicMock(return_value=mock_tracer)
|
|
mock_telemetry.inject_trace_headers = MagicMock(side_effect=lambda h: h)
|
|
mock_telemetry.get_current_traceparent = MagicMock(return_value="")
|
|
for attr in ["A2A_SOURCE_WORKSPACE", "A2A_TARGET_WORKSPACE", "A2A_TASK_ID", "WORKSPACE_ID_ATTR"]:
|
|
setattr(mock_telemetry, attr, attr)
|
|
|
|
monkeypatch.setitem(sys.modules, "builtin_tools.audit", mock_audit)
|
|
monkeypatch.setitem(sys.modules, "builtin_tools.telemetry", mock_telemetry)
|
|
monkeypatch.setenv("WORKSPACE_ID", "ws-self")
|
|
monkeypatch.setenv("PLATFORM_URL", "http://test:8080")
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
"builtin_tools.delegation",
|
|
os.path.join(os.path.dirname(__file__), "..", "builtin_tools", "delegation.py"),
|
|
)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
monkeypatch.setitem(sys.modules, "builtin_tools.delegation", mod)
|
|
spec.loader.exec_module(mod)
|
|
|
|
mod.DELEGATION_RETRY_ATTEMPTS = 2
|
|
mod.DELEGATION_RETRY_DELAY = 0.0
|
|
# Clear state between tests
|
|
mod._delegations.clear()
|
|
mod._background_tasks.clear()
|
|
|
|
return mod, mock_audit, mock_telemetry, mock_span
|
|
|
|
|
|
async def _invoke(mod, workspace_id="target", task="do stuff"):
|
|
"""Call delegate_to_workspace and return the immediate result."""
|
|
fn = mod.delegate_to_workspace
|
|
if hasattr(fn, "ainvoke"):
|
|
return await fn.ainvoke({"workspace_id": workspace_id, "task": task})
|
|
return await fn(workspace_id=workspace_id, task=task)
|
|
|
|
|
|
async def _invoke_and_wait(mod, workspace_id="target", task="do stuff"):
|
|
"""Call delegate_to_workspace, wait for background task, return status."""
|
|
result = await _invoke(mod, workspace_id, task)
|
|
# Wait for all background tasks to complete
|
|
if mod._background_tasks:
|
|
await asyncio.gather(*mod._background_tasks, return_exceptions=True)
|
|
# Get final status
|
|
if "task_id" in result:
|
|
fn = mod.check_delegation_status
|
|
if hasattr(fn, "ainvoke"):
|
|
return await fn.ainvoke({"task_id": result["task_id"]})
|
|
return await fn(task_id=result["task_id"])
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRBAC:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rbac_deny(self, delegation_mocks):
|
|
mod, mock_audit, *_ = delegation_mocks
|
|
mock_audit.check_permission.return_value = False
|
|
|
|
result = await _invoke(mod)
|
|
|
|
assert result["success"] is False
|
|
assert "RBAC" in result["error"]
|
|
|
|
|
|
class TestAsyncDelegation:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_immediately_with_task_id(self, delegation_mocks):
|
|
mod, *_ = delegation_mocks
|
|
_, mock_cls = _make_mock_client()
|
|
|
|
with patch("httpx.AsyncClient", mock_cls):
|
|
result = await _invoke(mod)
|
|
|
|
assert result["success"] is True
|
|
assert "task_id" in result
|
|
assert result["status"] == "delegated"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_background_task_completes(self, delegation_mocks):
|
|
mod, *_ = delegation_mocks
|
|
_, mock_cls = _make_mock_client()
|
|
|
|
with patch("httpx.AsyncClient", mock_cls):
|
|
status = await _invoke_and_wait(mod)
|
|
|
|
assert status["status"] == "completed"
|
|
assert "done" in status["result"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_delegation_list_all(self, delegation_mocks):
|
|
mod, *_ = delegation_mocks
|
|
_, mock_cls = _make_mock_client()
|
|
|
|
with patch("httpx.AsyncClient", mock_cls):
|
|
await _invoke(mod, workspace_id="ws-a", task="task A")
|
|
await _invoke(mod, workspace_id="ws-b", task="task B")
|
|
|
|
fn = mod.check_delegation_status
|
|
if hasattr(fn, "ainvoke"):
|
|
result = await fn.ainvoke({"task_id": ""})
|
|
else:
|
|
result = await fn(task_id="")
|
|
|
|
assert result["count"] == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_delegation_not_found(self, delegation_mocks):
|
|
mod, *_ = delegation_mocks
|
|
|
|
fn = mod.check_delegation_status
|
|
if hasattr(fn, "ainvoke"):
|
|
result = await fn.ainvoke({"task_id": "nonexistent"})
|
|
else:
|
|
result = await fn(task_id="nonexistent")
|
|
|
|
assert "error" in result
|
|
|
|
|
|
class TestDiscovery:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_403(self, delegation_mocks):
|
|
mod, *_ = delegation_mocks
|
|
_, mock_cls = _make_mock_client(discover_status=403)
|
|
|
|
with patch("httpx.AsyncClient", mock_cls):
|
|
status = await _invoke_and_wait(mod)
|
|
|
|
assert status["status"] == "failed"
|
|
assert "Discovery failed" in status.get("error", "")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_404(self, delegation_mocks):
|
|
mod, *_ = delegation_mocks
|
|
_, mock_cls = _make_mock_client(discover_status=404)
|
|
|
|
with patch("httpx.AsyncClient", mock_cls):
|
|
status = await _invoke_and_wait(mod)
|
|
|
|
assert status["status"] == "failed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_no_url(self, delegation_mocks):
|
|
mod, *_ = delegation_mocks
|
|
_, mock_cls = _make_mock_client(discover_payload={"url": ""})
|
|
|
|
with patch("httpx.AsyncClient", mock_cls):
|
|
status = await _invoke_and_wait(mod)
|
|
|
|
assert status["status"] == "failed"
|
|
assert "No URL" in status.get("error", "")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discovery_exception(self, delegation_mocks):
|
|
mod, *_ = delegation_mocks
|
|
_, mock_cls = _make_mock_client(discover_exc=Exception("dns fail"))
|
|
|
|
with patch("httpx.AsyncClient", mock_cls):
|
|
status = await _invoke_and_wait(mod)
|
|
|
|
assert status["status"] == "failed"
|
|
assert "dns fail" in status.get("error", "")
|
|
|
|
|
|
class TestA2ASuccess:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_success_with_parts(self, delegation_mocks):
|
|
mod, *_ = delegation_mocks
|
|
_, mock_cls = _make_mock_client(
|
|
a2a_payload={"result": {"parts": [{"kind": "text", "text": "hello world"}]}}
|
|
)
|
|
|
|
with patch("httpx.AsyncClient", mock_cls):
|
|
status = await _invoke_and_wait(mod)
|
|
|
|
assert status["status"] == "completed"
|
|
assert "hello world" in status["result"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_success_with_artifacts(self, delegation_mocks):
|
|
mod, *_ = delegation_mocks
|
|
_, mock_cls = _make_mock_client(
|
|
a2a_payload={
|
|
"result": {
|
|
"artifacts": [{"parts": [{"kind": "text", "text": "artifact text"}]}],
|
|
"parts": [],
|
|
}
|
|
}
|
|
)
|
|
|
|
with patch("httpx.AsyncClient", mock_cls):
|
|
status = await _invoke_and_wait(mod)
|
|
|
|
assert status["status"] == "completed"
|
|
assert "artifact text" in status["result"]
|
|
|
|
|
|
class TestA2AErrors:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rpc_error(self, delegation_mocks):
|
|
mod, *_ = delegation_mocks
|
|
_, mock_cls = _make_mock_client(
|
|
a2a_payload={"error": {"message": "internal error"}}
|
|
)
|
|
|
|
with patch("httpx.AsyncClient", mock_cls):
|
|
status = await _invoke_and_wait(mod)
|
|
|
|
assert status["status"] == "failed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_network_error(self, delegation_mocks):
|
|
mod, *_ = delegation_mocks
|
|
mock_client, mock_cls = _make_mock_client()
|
|
mock_client.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
|
|
|
|
with patch("httpx.AsyncClient", mock_cls):
|
|
status = await _invoke_and_wait(mod)
|
|
|
|
assert status["status"] == "failed"
|
|
assert "refused" in status.get("error", "")
|
|
|
|
|
|
# ---------- #64: platform-mirroring helpers ----------
|
|
|
|
import asyncio as _asyncio_64
|
|
from unittest.mock import AsyncMock as _AsyncMock_64, patch as _patch_64
|
|
|
|
|
|
def test_record_delegation_on_platform_fires_http_post(delegation_mocks):
|
|
"""Agent registers the delegation on the platform so GET /delegations sees it."""
|
|
mod, _, _, _ = delegation_mocks
|
|
|
|
calls = []
|
|
|
|
class FakeClient:
|
|
def __init__(self, *a, **kw): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): return False
|
|
async def post(self, url, json=None):
|
|
calls.append({"url": url, "json": json})
|
|
class R:
|
|
status_code = 202
|
|
return R()
|
|
|
|
with _patch_64.object(mod.httpx, "AsyncClient", FakeClient):
|
|
with _patch_64.object(mod, "WORKSPACE_ID", "src-ws"), \
|
|
_patch_64.object(mod, "PLATFORM_URL", "http://platform"):
|
|
_asyncio_64.run(
|
|
mod._record_delegation_on_platform("task-1", "target-ws", "hello")
|
|
)
|
|
|
|
assert len(calls) == 1
|
|
assert calls[0]["url"] == "http://platform/workspaces/src-ws/delegations/record"
|
|
body = calls[0]["json"]
|
|
assert body == {"target_id": "target-ws", "task": "hello", "delegation_id": "task-1"}
|
|
|
|
|
|
def test_record_delegation_on_platform_best_effort_on_error(delegation_mocks):
|
|
"""Platform unreachable must NOT block the A2A delegation path."""
|
|
mod, _, _, _ = delegation_mocks
|
|
|
|
class FailingClient:
|
|
def __init__(self, *a, **kw): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): return False
|
|
async def post(self, *a, **kw):
|
|
raise RuntimeError("platform unreachable")
|
|
|
|
with _patch_64.object(mod.httpx, "AsyncClient", FailingClient):
|
|
with _patch_64.object(mod, "WORKSPACE_ID", "src-ws"), \
|
|
_patch_64.object(mod, "PLATFORM_URL", "http://platform"):
|
|
# Must not raise
|
|
_asyncio_64.run(
|
|
mod._record_delegation_on_platform("task-1", "target-ws", "hello")
|
|
)
|
|
|
|
|
|
def test_update_delegation_on_platform_completed(delegation_mocks):
|
|
mod, _, _, _ = delegation_mocks
|
|
calls = []
|
|
|
|
class FakeClient:
|
|
def __init__(self, *a, **kw): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): return False
|
|
async def post(self, url, json=None):
|
|
calls.append({"url": url, "json": json})
|
|
class R:
|
|
status_code = 200
|
|
return R()
|
|
|
|
with _patch_64.object(mod.httpx, "AsyncClient", FakeClient):
|
|
with _patch_64.object(mod, "WORKSPACE_ID", "src-ws"), \
|
|
_patch_64.object(mod, "PLATFORM_URL", "http://platform"):
|
|
_asyncio_64.run(
|
|
mod._update_delegation_on_platform(
|
|
"task-1", "completed", "", "the result text"
|
|
)
|
|
)
|
|
|
|
assert calls[0]["url"] == "http://platform/workspaces/src-ws/delegations/task-1/update"
|
|
assert calls[0]["json"]["status"] == "completed"
|
|
assert calls[0]["json"]["response_preview"] == "the result text"
|
|
|
|
|
|
def test_update_delegation_on_platform_truncates_large_preview(delegation_mocks):
|
|
"""500-char cap protects log volume + mirrors the platform's 300-char truncate."""
|
|
mod, _, _, _ = delegation_mocks
|
|
calls = []
|
|
|
|
class FakeClient:
|
|
def __init__(self, *a, **kw): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): return False
|
|
async def post(self, url, json=None):
|
|
calls.append({"url": url, "json": json})
|
|
class R:
|
|
status_code = 200
|
|
return R()
|
|
|
|
huge = "X" * 10000
|
|
with _patch_64.object(mod.httpx, "AsyncClient", FakeClient):
|
|
with _patch_64.object(mod, "WORKSPACE_ID", "src-ws"), \
|
|
_patch_64.object(mod, "PLATFORM_URL", "http://platform"):
|
|
_asyncio_64.run(
|
|
mod._update_delegation_on_platform("task-1", "completed", "", huge)
|
|
)
|
|
assert len(calls[0]["json"]["response_preview"]) == 500
|