mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
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
549 lines
20 KiB
Python
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()
|