nanobot/tests/channels/test_mailbox.py
chengyongru 6daf91c620 feat(mailbox): add multi-agent mailbox channel plugin
File-system-based mailbox channel for inter-agent communication.
Zero modifications to existing code — pure channel plugin.

- MailboxManager: atomic file I/O, registry CRUD, message send/poll
- MailboxConfig: Pydantic config with camelCase aliasing
- MailboxChannel: polls inbox, routes messages via bus, callback routing
- Anti-loop protection: TTL decrement + circular trace detection
- allowFrom access control inherited from BaseChannel
- 29 unit/integration tests + standalone smoke test script

Design: docs/superpowers/specs/2026-04-26-multi-agent-mailbox-design.md
Plan: docs/superpowers/plans/2026-04-26-multi-agent-mailbox.md
2026-04-27 01:14:12 +08:00

549 lines
20 KiB
Python

"""Tests for MailboxManager (file operations) and MailboxChannel."""
import asyncio
import json
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.channels.mailbox import MailboxManager, MailboxChannel, MailboxConfig
# --- MailboxManager Tests ---
@pytest.fixture
def root(tmp_path: Path) -> Path:
mailboxes = tmp_path / "mailboxes"
mailboxes.mkdir()
return mailboxes
@pytest.fixture
def mgr(root: Path) -> MailboxManager:
return MailboxManager(root)
class TestRegister:
def test_register_creates_agent_entry(self, mgr: MailboxManager, root: Path):
card = {"agent_id": "researcher", "description": "test agent"}
mgr.register("researcher", card)
registry = json.loads((root / "_registry.json").read_text())
assert "researcher" in registry
assert registry["researcher"]["agent_id"] == "researcher"
def test_register_creates_directories(self, mgr: MailboxManager, root: Path):
mgr.register("coder", {"agent_id": "coder"})
assert (root / "coder" / "inbox").is_dir()
assert (root / "coder" / "processed").is_dir()
def test_register_overwrite(self, mgr: MailboxManager, root: Path):
mgr.register("coder", {"agent_id": "coder", "status": "idle"})
mgr.register("coder", {"agent_id": "coder", "status": "busy"})
registry = json.loads((root / "_registry.json").read_text())
assert registry["coder"]["status"] == "busy"
class TestHeartbeat:
def test_heartbeat_updates_timestamp(self, mgr: MailboxManager, root: Path):
mgr.register("coder", {"agent_id": "coder"})
before = json.loads((root / "_registry.json").read_text())["coder"]["last_heartbeat"]
time.sleep(0.01)
mgr.heartbeat("coder")
after = json.loads((root / "_registry.json").read_text())["coder"]["last_heartbeat"]
assert after >= before
class TestUpdateStatus:
def test_update_status(self, mgr: MailboxManager, root: Path):
mgr.register("coder", {"agent_id": "coder", "status": "idle"})
mgr.update_status("coder", "busy", current_tasks=["task_1"])
registry = json.loads((root / "_registry.json").read_text())
assert registry["coder"]["status"] == "busy"
assert registry["coder"]["current_tasks"] == ["task_1"]
class TestSendAndPoll:
def test_send_creates_message_file(self, mgr: MailboxManager, root: Path):
mgr.register("coder", {"agent_id": "coder"})
mgr.register("researcher", {"agent_id": "researcher"})
msg = {"type": "message", "content": {"parts": [{"type": "text", "text": "hello"}]}}
mgr.send("researcher", "coder", msg)
files = list((root / "coder" / "inbox").glob("*.msg.json"))
assert len(files) == 1
data = json.loads(files[0].read_text())
assert data["from"] == "researcher"
assert data["to"] == "coder"
def test_poll_returns_new_messages_sorted(self, mgr: MailboxManager, root: Path):
mgr.register("coder", {"agent_id": "coder"})
mgr.register("a1", {"agent_id": "a1"})
mgr.register("a2", {"agent_id": "a2"})
mgr.send("a1", "coder", {"type": "message", "content": {"parts": []}})
time.sleep(0.01)
mgr.send("a2", "coder", {"type": "task", "content": {"parts": []}})
messages = mgr.poll("coder")
assert len(messages) == 2
assert messages[0]["from"] == "a1"
assert messages[1]["from"] == "a2"
def test_mark_processed_moves_file(self, mgr: MailboxManager, root: Path):
mgr.register("coder", {"agent_id": "coder"})
mgr.register("researcher", {"agent_id": "researcher"})
mgr.send("researcher", "coder", {"type": "message", "content": {"parts": []}})
messages = mgr.poll("coder")
mgr.mark_processed("coder", messages[0]["_filename"])
assert len(list((root / "coder" / "inbox").glob("*.msg.json"))) == 0
assert len(list((root / "coder" / "processed").glob("*.msg.json"))) == 1
class TestListAndGetAgents:
def test_list_online_agents(self, mgr: MailboxManager, root: Path):
mgr.register("researcher", {"agent_id": "researcher", "status": "idle"})
mgr.register("coder", {"agent_id": "coder", "status": "busy"})
agents = mgr.list_online_agents()
ids = {a["agent_id"] for a in agents}
assert ids == {"researcher", "coder"}
def test_get_agent_not_found(self, mgr: MailboxManager):
assert mgr.get_agent("nonexistent") is None
# --- MailboxChannel & integration Tests ---
def _make_channel(bus, root, agent_id, **overrides):
"""Create a MailboxChannel with sensible defaults."""
cfg = {
"enabled": True,
"agent_id": agent_id,
"description": f"Test agent {agent_id}",
"mailboxes_root": str(root),
"poll_interval": 0.05,
"allow_from": ["*"],
}
cfg.update(overrides)
return MailboxChannel(cfg, bus)
class TestConfig:
def test_default_config(self):
cfg = MailboxConfig()
assert cfg.enabled is False
assert cfg.agent_id == ""
assert cfg.allow_from == ["*"]
def test_config_from_dict(self):
"""Validate from dict with camelCase aliases."""
cfg = MailboxConfig.model_validate({
"enabled": True,
"agentId": "coder",
"allowFrom": ["researcher"],
"pollInterval": 2.0,
})
assert cfg.enabled is True
assert cfg.agent_id == "coder"
assert cfg.allow_from == ["researcher"]
assert cfg.poll_interval == 2.0
class TestChannelAttributes:
def test_name(self, root: Path):
bus = MagicMock()
ch = _make_channel(bus, root, "test")
assert ch.name == "mailbox"
def test_display_name(self, root: Path):
bus = MagicMock()
ch = _make_channel(bus, root, "test")
assert ch.display_name == "Mailbox"
class TestStartAndStop:
@pytest.mark.asyncio
async def test_start_registers_agent(self, root: Path):
bus = MagicMock()
bus.publish_inbound = AsyncMock()
ch = _make_channel(bus, root, "coder", description="A coding agent")
try:
await ch.start()
card = ch.manager.get_agent("coder")
assert card is not None
assert card["agent_id"] == "coder"
assert card["description"] == "A coding agent"
finally:
ch._running = False
if ch._poll_task:
ch._poll_task.cancel()
try:
await ch._poll_task
except asyncio.CancelledError:
pass
@pytest.mark.asyncio
async def test_stop_marks_offline(self, root: Path):
bus = MagicMock()
bus.publish_inbound = AsyncMock()
ch = _make_channel(bus, root, "coder")
await ch.start()
await ch.stop()
card = ch.manager.get_agent("coder")
assert card["status"] == "offline"
@pytest.mark.asyncio
async def test_stop_idempotent(self, root: Path):
bus = MagicMock()
bus.publish_inbound = AsyncMock()
ch = _make_channel(bus, root, "coder")
await ch.start()
await ch.stop()
# Calling stop() a second time should not raise
await ch.stop()
class TestPollAndInbound:
@pytest.mark.asyncio
async def test_poll_delivers_inbound_message(self, root: Path):
bus = MagicMock()
bus.publish_inbound = AsyncMock()
ch = _make_channel(bus, root, "coder")
await ch.start()
try:
# Simulate researcher sending a message to coder
ch.manager.send("researcher", "coder", {
"type": "message",
"content": {"parts": [{"type": "text", "text": "Hello from researcher"}]},
})
# Manually trigger a poll
await ch._poll_once()
bus.publish_inbound.assert_awaited_once()
inbound: InboundMessage = bus.publish_inbound.call_args[0][0]
assert inbound.channel == "mailbox"
assert inbound.sender_id == "researcher"
assert inbound.chat_id == "researcher"
assert "Hello from researcher" in inbound.content
finally:
await ch.stop()
@pytest.mark.asyncio
async def test_poll_with_callback_routes_to_original_session(self, root: Path):
bus = MagicMock()
bus.publish_inbound = AsyncMock()
ch = _make_channel(bus, root, "coder")
await ch.start()
try:
# Message with callback routing info
ch.manager.send("researcher", "coder", {
"type": "message",
"from": "researcher",
"content": {"parts": [{"type": "text", "text": "Task result"}]},
"callback": {
"channel": "feishu",
"chat_id": "oc_abc123",
"session_id": "sess_xyz",
},
})
await ch._poll_once()
bus.publish_inbound.assert_awaited_once()
inbound: InboundMessage = bus.publish_inbound.call_args[0][0]
assert inbound.channel == "feishu"
assert inbound.chat_id == "oc_abc123"
assert inbound.session_key_override == "sess_xyz"
finally:
await ch.stop()
@pytest.mark.asyncio
async def test_poll_respects_allow_from(self, root: Path):
bus = MagicMock()
bus.publish_inbound = AsyncMock()
ch = _make_channel(bus, root, "coder", allow_from=["researcher"])
await ch.start()
try:
# Message from a stranger should be blocked
ch.manager.send("stranger", "coder", {
"type": "message",
"content": {"parts": [{"type": "text", "text": "Hi"}]},
})
await ch._poll_once()
bus.publish_inbound.assert_not_awaited()
finally:
await ch.stop()
@pytest.mark.asyncio
async def test_poll_marks_processed(self, root: Path):
bus = MagicMock()
bus.publish_inbound = AsyncMock()
ch = _make_channel(bus, root, "coder")
await ch.start()
try:
ch.manager.send("researcher", "coder", {
"type": "message",
"content": {"parts": [{"type": "text", "text": "hello"}]},
})
await ch._poll_once()
# Inbox should be empty
assert len(ch.manager.poll("coder")) == 0
# Processed dir should have the file
processed = list((root / "coder" / "processed").glob("*.msg.json"))
assert len(processed) == 1
finally:
await ch.stop()
class TestSend:
@pytest.mark.asyncio
async def test_send_writes_to_target_mailbox(self, root: Path):
bus = MagicMock()
bus.publish_inbound = AsyncMock()
ch = _make_channel(bus, root, "coder")
await ch.start()
try:
msg = OutboundMessage(
channel="mailbox",
chat_id="researcher",
content="Hello researcher",
)
await ch.send(msg)
# Check target inbox
files = list((root / "researcher" / "inbox").glob("*.msg.json"))
assert len(files) == 1
data = json.loads(files[0].read_text())
assert data["from"] == "coder"
assert data["to"] == "researcher"
assert data["ttl"] == 2 # started at 3, decremented to 2
assert data["trace"] == ["coder"]
finally:
await ch.stop()
@pytest.mark.asyncio
async def test_send_uses_existing_message_tool(self, root: Path):
bus = MagicMock()
bus.publish_inbound = AsyncMock()
ch = _make_channel(bus, root, "coder")
await ch.start()
try:
# Simulate what MessageTool would produce — metadata carrying task info
msg = OutboundMessage(
channel="mailbox",
chat_id="researcher",
content="Here is the result",
metadata={
"mailbox_task": "analyze_code",
"mailbox_type": "task_result",
},
)
await ch.send(msg)
files = list((root / "researcher" / "inbox").glob("*.msg.json"))
assert len(files) == 1
data = json.loads(files[0].read_text())
assert data["type"] == "task_result"
assert data["task"] == "analyze_code"
finally:
await ch.stop()
class TestTwoAgentCommunication:
@pytest.mark.asyncio
async def test_agent_a_sends_agent_b_receives(self, root: Path):
bus_a = MagicMock()
bus_a.publish_inbound = AsyncMock()
bus_b = MagicMock()
bus_b.publish_inbound = AsyncMock()
ch_a = _make_channel(bus_a, root, "alpha")
ch_b = _make_channel(bus_b, root, "beta")
await ch_a.start()
await ch_b.start()
try:
# Alpha sends to Beta
out = OutboundMessage(
channel="mailbox",
chat_id="beta",
content="Hello Beta, this is Alpha",
)
await ch_a.send(out)
# Beta polls and receives
await ch_b._poll_once()
bus_b.publish_inbound.assert_awaited_once()
inbound: InboundMessage = bus_b.publish_inbound.call_args[0][0]
assert inbound.channel == "mailbox"
assert inbound.sender_id == "alpha"
assert "Hello Beta" in inbound.content
finally:
await ch_a.stop()
await ch_b.stop()
@pytest.mark.asyncio
async def test_agent_b_sends_response_back(self, root: Path):
bus_a = MagicMock()
bus_a.publish_inbound = AsyncMock()
bus_b = MagicMock()
bus_b.publish_inbound = AsyncMock()
ch_a = _make_channel(bus_a, root, "alpha")
ch_b = _make_channel(bus_b, root, "beta")
await ch_a.start()
await ch_b.start()
try:
# Beta sends to Alpha
out = OutboundMessage(
channel="mailbox",
chat_id="alpha",
content="Response from Beta",
)
await ch_b.send(out)
# Alpha polls and receives
await ch_a._poll_once()
bus_a.publish_inbound.assert_awaited_once()
inbound: InboundMessage = bus_a.publish_inbound.call_args[0][0]
assert inbound.sender_id == "beta"
assert "Response from Beta" in inbound.content
finally:
await ch_a.stop()
await ch_b.stop()
@pytest.mark.asyncio
async def test_callback_routes_to_original_feishu_session(self, root: Path):
"""A sends task with callback -> B receives -> B responds with callback -> A receives routed to feishu session."""
bus_a = MagicMock()
bus_a.publish_inbound = AsyncMock()
bus_b = MagicMock()
bus_b.publish_inbound = AsyncMock()
ch_a = _make_channel(bus_a, root, "alpha")
ch_b = _make_channel(bus_b, root, "beta")
await ch_a.start()
await ch_b.start()
try:
# Alpha sends a task to Beta with a callback to feishu
out = OutboundMessage(
channel="mailbox",
chat_id="beta",
content="Analyze this code",
metadata={
"mailbox_task": "code_review",
"mailbox_callback": {
"channel": "feishu",
"chat_id": "oc_feishu_chat",
"session_id": "sess_feishu_123",
},
},
)
await ch_a.send(out)
# Beta polls and receives
await ch_b._poll_once()
bus_b.publish_inbound.assert_awaited_once()
inbound_b: InboundMessage = bus_b.publish_inbound.call_args[0][0]
assert "Analyze this code" in inbound_b.content
# Beta responds, echoing the callback
out_b = OutboundMessage(
channel="mailbox",
chat_id="alpha",
content="Code review complete",
metadata={
"mailbox_task": "code_review",
"mailbox_callback": {
"channel": "feishu",
"chat_id": "oc_feishu_chat",
"session_id": "sess_feishu_123",
},
},
)
await ch_b.send(out_b)
# Alpha polls — the callback routes to feishu channel
await ch_a._poll_once()
bus_a.publish_inbound.assert_awaited_once()
inbound_a: InboundMessage = bus_a.publish_inbound.call_args[0][0]
assert inbound_a.channel == "feishu"
assert inbound_a.chat_id == "oc_feishu_chat"
assert inbound_a.session_key_override == "sess_feishu_123"
assert "Code review complete" in inbound_a.content
finally:
await ch_a.stop()
await ch_b.stop()
@pytest.mark.asyncio
async def test_anti_loop_trace(self, root: Path):
"""Message with existing trace preserved in metadata."""
bus_a = MagicMock()
bus_a.publish_inbound = AsyncMock()
ch_a = _make_channel(bus_a, root, "alpha")
await ch_a.start()
try:
# Send with pre-existing trace
out = OutboundMessage(
channel="mailbox",
chat_id="beta",
content="Forwarding along",
metadata={
"mailbox_trace": ["origin", "relay1"],
"mailbox_ttl": 5,
},
)
await ch_a.send(out)
files = list((root / "beta" / "inbox").glob("*.msg.json"))
assert len(files) == 1
data = json.loads(files[0].read_text())
# Trace should be: origin, relay1, alpha
assert data["trace"] == ["origin", "relay1", "alpha"]
assert data["ttl"] == 4 # decremented from 5
finally:
await ch_a.stop()
@pytest.mark.asyncio
async def test_allow_from_blocks_unauthorized(self, root: Path):
"""allowFrom=['coder'] blocks stranger."""
bus = MagicMock()
bus.publish_inbound = AsyncMock()
ch = _make_channel(bus, root, "coder", allow_from=["coder"])
await ch.start()
try:
# Stranger sends to coder
ch.manager.send("stranger", "coder", {
"type": "message",
"content": {"parts": [{"type": "text", "text": "Spam"}]},
})
await ch._poll_once()
bus.publish_inbound.assert_not_awaited()
finally:
await ch.stop()
@pytest.mark.asyncio
async def test_registry_discovery(self, root: Path):
"""Both agents register and can discover each other."""
bus_a = MagicMock()
bus_a.publish_inbound = AsyncMock()
bus_b = MagicMock()
bus_b.publish_inbound = AsyncMock()
ch_a = _make_channel(bus_a, root, "alpha", description="Agent Alpha")
ch_b = _make_channel(bus_b, root, "beta", description="Agent Beta")
await ch_a.start()
await ch_b.start()
try:
agents = ch_a.manager.list_online_agents()
ids = {a["agent_id"] for a in agents}
assert "alpha" in ids
assert "beta" in ids
# Alpha can look up Beta's card
beta_card = ch_a.manager.get_agent("beta")
assert beta_card is not None
assert beta_card["description"] == "Agent Beta"
# Beta can look up Alpha's card
alpha_card = ch_b.manager.get_agent("alpha")
assert alpha_card is not None
assert alpha_card["description"] == "Agent Alpha"
finally:
await ch_a.stop()
await ch_b.stop()