feat: implement channel runtime connectors
This commit is contained in:
414
app-instance/backend/tests/unit/test_channel_runtime.py
Normal file
414
app-instance/backend/tests/unit/test_channel_runtime.py
Normal file
@ -0,0 +1,414 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from beaver.foundation.config.schema import ChannelConfig
|
||||
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
|
||||
from beaver.foundation.events import MessageBus
|
||||
from beaver.interfaces.channels.generic_webhook import GenericWebhookAdapter
|
||||
from beaver.interfaces.channels.runtime import ChannelRuntime
|
||||
from beaver.interfaces.channels.state import ChannelDedupeStore, ChannelEventLog
|
||||
from beaver.interfaces.web.app import _self_restart_enabled, create_app
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
def test_channel_identity_builds_stable_session_id() -> None:
|
||||
identity = ChannelIdentity(
|
||||
channel_id="webhook-dev",
|
||||
kind="webhook",
|
||||
account_id="local",
|
||||
peer_id="demo-user",
|
||||
thread_id="main",
|
||||
peer_type="dm",
|
||||
message_id="msg-1",
|
||||
)
|
||||
|
||||
assert identity.session_id() == "webhook-dev:local:demo-user:main"
|
||||
assert identity.dedupe_key() == "webhook-dev:local:demo-user:main:msg-1"
|
||||
|
||||
|
||||
def test_channel_identity_requires_routing_fields() -> None:
|
||||
identity = ChannelIdentity(channel_id="webhook-dev", kind="webhook", account_id="", peer_id="demo")
|
||||
|
||||
assert identity.validation_error() == "account_id is required"
|
||||
|
||||
|
||||
def test_messages_carry_channel_identity() -> None:
|
||||
identity = ChannelIdentity(
|
||||
channel_id="webhook-dev",
|
||||
kind="webhook",
|
||||
account_id="local",
|
||||
peer_id="demo-user",
|
||||
message_id="msg-1",
|
||||
)
|
||||
|
||||
inbound = InboundMessage(channel="webhook-dev", content="hello", channel_identity=identity)
|
||||
outbound = OutboundMessage(
|
||||
channel="webhook-dev",
|
||||
content="ok",
|
||||
session_id=identity.session_id(),
|
||||
finish_reason="stop",
|
||||
channel_identity=identity,
|
||||
)
|
||||
|
||||
assert inbound.channel_identity is identity
|
||||
assert outbound.channel_identity is identity
|
||||
|
||||
|
||||
def test_dedupe_store_tracks_processing_and_done(tmp_path) -> None:
|
||||
store = ChannelDedupeStore(tmp_path / "dedupe.json", retention_hours=48)
|
||||
|
||||
created = store.mark_processing(
|
||||
dedupe_key="webhook-dev:local:demo:msg-1",
|
||||
session_id="webhook-dev:local:demo",
|
||||
message_id="msg-1",
|
||||
)
|
||||
duplicate = store.mark_processing(
|
||||
dedupe_key="webhook-dev:local:demo:msg-1",
|
||||
session_id="webhook-dev:local:demo",
|
||||
message_id="msg-1",
|
||||
)
|
||||
|
||||
assert created.created is True
|
||||
assert duplicate.created is False
|
||||
assert duplicate.record is not None
|
||||
assert duplicate.record["status"] == "processing"
|
||||
|
||||
store.mark_done(
|
||||
dedupe_key="webhook-dev:local:demo:msg-1",
|
||||
run_id="run-1",
|
||||
reply="hello" * 10000,
|
||||
max_reply_chars=20,
|
||||
)
|
||||
|
||||
done = store.get("webhook-dev:local:demo:msg-1")
|
||||
assert done is not None
|
||||
assert done["status"] == "done"
|
||||
assert done["reply"] == "hellohellohellohello"
|
||||
|
||||
|
||||
def test_channel_event_log_writes_recent_events(tmp_path) -> None:
|
||||
log = ChannelEventLog(tmp_path / "events.jsonl")
|
||||
log.record(
|
||||
channel_id="webhook-dev",
|
||||
kind="inbound_accepted",
|
||||
session_id="webhook-dev:local:demo",
|
||||
message_id="msg-1",
|
||||
status="ok",
|
||||
text="hello world",
|
||||
)
|
||||
|
||||
events = log.recent(channel_id="webhook-dev", limit=10)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0]["kind"] == "inbound_accepted"
|
||||
assert events[0]["text_preview"] == "hello world"
|
||||
assert "raw_channel_payload" not in json.dumps(events[0])
|
||||
|
||||
|
||||
class FakeAgentService:
|
||||
is_running = True
|
||||
|
||||
async def handle_inbound_message(self, inbound):
|
||||
return OutboundMessage(
|
||||
message_id=inbound.message_id,
|
||||
channel=inbound.channel,
|
||||
content=f"echo:{inbound.content}",
|
||||
session_id=inbound.session_id,
|
||||
finish_reason="stop",
|
||||
run_id="run-1",
|
||||
channel_identity=inbound.channel_identity,
|
||||
)
|
||||
|
||||
|
||||
class SlowFakeAgentService(FakeAgentService):
|
||||
async def handle_inbound_message(self, inbound):
|
||||
await asyncio.sleep(0.05)
|
||||
return await super().handle_inbound_message(inbound)
|
||||
|
||||
|
||||
def test_channel_runtime_accept_inbound_normalizes_session_and_dedupes(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
bus = MessageBus()
|
||||
runtime = ChannelRuntime(
|
||||
service=FakeAgentService(),
|
||||
bus=bus,
|
||||
workspace=tmp_path,
|
||||
channels={},
|
||||
)
|
||||
identity = ChannelIdentity(
|
||||
channel_id="webhook-dev",
|
||||
kind="webhook",
|
||||
account_id="local",
|
||||
peer_id="demo",
|
||||
message_id="msg-1",
|
||||
)
|
||||
result = await runtime.accept_inbound(
|
||||
InboundMessage(
|
||||
channel="webhook-dev",
|
||||
content="hello",
|
||||
session_id="wrong",
|
||||
channel_identity=identity,
|
||||
)
|
||||
)
|
||||
duplicate = await runtime.accept_inbound(
|
||||
InboundMessage(
|
||||
channel="webhook-dev",
|
||||
content="hello",
|
||||
channel_identity=identity,
|
||||
)
|
||||
)
|
||||
|
||||
queued = await bus.consume_inbound()
|
||||
assert result.accepted is True
|
||||
assert queued.session_id == "webhook-dev:local:demo"
|
||||
assert duplicate.accepted is False
|
||||
assert duplicate.duplicate is True
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_generic_webhook_adapter_waits_for_outbound_reply(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
bus = MessageBus()
|
||||
runtime = ChannelRuntime(
|
||||
service=FakeAgentService(),
|
||||
bus=bus,
|
||||
workspace=tmp_path,
|
||||
channels={},
|
||||
)
|
||||
adapter = GenericWebhookAdapter(
|
||||
channel_id="webhook-dev",
|
||||
kind="webhook",
|
||||
mode="webhook",
|
||||
account_id="local",
|
||||
display_name="Webhook Dev",
|
||||
inbound_sink=runtime,
|
||||
response_timeout_seconds=1,
|
||||
)
|
||||
runtime.manager.register(adapter)
|
||||
await runtime.start()
|
||||
try:
|
||||
response = await adapter.handle_webhook_payload(
|
||||
{
|
||||
"peer_id": "demo",
|
||||
"message_id": "msg-1",
|
||||
"text": "hello",
|
||||
"peer_type": "dm",
|
||||
}
|
||||
)
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
assert response["ok"] is True
|
||||
assert response["reply"] == "echo:hello"
|
||||
assert response["session_id"] == "webhook-dev:local:demo"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_generic_webhook_records_unclaimed_outbound_after_timeout(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
bus = MessageBus()
|
||||
runtime = ChannelRuntime(
|
||||
service=SlowFakeAgentService(),
|
||||
bus=bus,
|
||||
workspace=tmp_path,
|
||||
channels={},
|
||||
)
|
||||
adapter = GenericWebhookAdapter(
|
||||
channel_id="webhook-dev",
|
||||
kind="webhook",
|
||||
mode="webhook",
|
||||
account_id="local",
|
||||
display_name="Webhook Dev",
|
||||
inbound_sink=runtime,
|
||||
response_timeout_seconds=1,
|
||||
)
|
||||
adapter.response_timeout_seconds = 0.01
|
||||
runtime.manager.register(adapter)
|
||||
await runtime.start()
|
||||
try:
|
||||
response = await adapter.handle_webhook_payload(
|
||||
{
|
||||
"peer_id": "demo",
|
||||
"message_id": "msg-1",
|
||||
"text": "hello",
|
||||
"peer_type": "dm",
|
||||
}
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
events = runtime.recent_events("webhook-dev", limit=20)
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
assert response["pending"] is True
|
||||
assert any(event["kind"] == "outbound_unclaimed" for event in events)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_channel_runtime_starts_enabled_generic_webhook_and_reports_status(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
runtime = ChannelRuntime(
|
||||
service=FakeAgentService(),
|
||||
workspace=tmp_path,
|
||||
channels={
|
||||
"webhook-dev": ChannelConfig(
|
||||
enabled=True,
|
||||
kind="webhook",
|
||||
mode="webhook",
|
||||
account_id="local",
|
||||
display_name="Webhook Dev",
|
||||
config={"response_timeout_seconds": 1800},
|
||||
),
|
||||
"off": ChannelConfig(
|
||||
enabled=False,
|
||||
kind="webhook",
|
||||
mode="webhook",
|
||||
account_id="local",
|
||||
),
|
||||
},
|
||||
)
|
||||
await runtime.start()
|
||||
try:
|
||||
statuses = runtime.statuses()
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
by_id = {item["channel_id"]: item for item in statuses}
|
||||
assert by_id["webhook-dev"]["state"] == "running"
|
||||
assert by_id["webhook-dev"]["webhook_url"] == "/api/channels/webhook-dev/webhook"
|
||||
assert by_id["off"]["state"] == "disabled"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_channel_runtime_builds_platform_adapters_without_starting_networks(tmp_path) -> None:
|
||||
runtime = ChannelRuntime(
|
||||
service=FakeAgentService(),
|
||||
workspace=tmp_path,
|
||||
channels={},
|
||||
)
|
||||
|
||||
cases = {
|
||||
"telegram-main": ChannelConfig(enabled=True, kind="telegram", mode="polling", account_id="bot-main"),
|
||||
"feishu-main": ChannelConfig(enabled=True, kind="feishu", mode="websocket", account_id="tenant-main"),
|
||||
"qq-main": ChannelConfig(enabled=True, kind="qqbot", mode="websocket", account_id="qq-main"),
|
||||
"weixin-main": ChannelConfig(enabled=True, kind="weixin", mode="polling", account_id="wx-main"),
|
||||
}
|
||||
|
||||
for channel_id, cfg in cases.items():
|
||||
adapter = runtime._build_adapter(channel_id, cfg)
|
||||
assert adapter.channel_id == channel_id
|
||||
assert adapter.kind == cfg.kind
|
||||
assert adapter.mode == cfg.mode
|
||||
|
||||
|
||||
def test_channel_runtime_reports_platform_capabilities(tmp_path) -> None:
|
||||
runtime = ChannelRuntime(
|
||||
service=FakeAgentService(),
|
||||
workspace=tmp_path,
|
||||
channels={
|
||||
"telegram-main": ChannelConfig(enabled=True, kind="telegram", mode="polling", account_id="bot-main"),
|
||||
"weixin-main": ChannelConfig(enabled=True, kind="weixin", mode="polling", account_id="wx-main"),
|
||||
},
|
||||
)
|
||||
|
||||
by_id = {item["channel_id"]: item for item in runtime.statuses()}
|
||||
|
||||
assert by_id["telegram-main"]["capabilities"] == [
|
||||
"receive_text",
|
||||
"send_text",
|
||||
"receive_media",
|
||||
"groups",
|
||||
]
|
||||
assert by_id["weixin-main"]["capabilities"] == [
|
||||
"receive_text",
|
||||
"send_text",
|
||||
"receive_media",
|
||||
"direct_messages",
|
||||
]
|
||||
|
||||
|
||||
def test_channel_runtime_platform_start_failure_does_not_stop_other_channels(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
runtime = ChannelRuntime(
|
||||
service=FakeAgentService(),
|
||||
workspace=tmp_path,
|
||||
channels={
|
||||
"telegram-main": ChannelConfig(
|
||||
enabled=True,
|
||||
kind="telegram",
|
||||
mode="polling",
|
||||
account_id="bot-main",
|
||||
secrets={},
|
||||
),
|
||||
"off": ChannelConfig(
|
||||
enabled=False,
|
||||
kind="weixin",
|
||||
mode="polling",
|
||||
account_id="wx-main",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
await runtime.start()
|
||||
try:
|
||||
by_id = {item["channel_id"]: item for item in runtime.statuses()}
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
assert by_id["telegram-main"]["state"] == "error"
|
||||
assert "botToken" in by_id["telegram-main"]["last_error"]
|
||||
assert by_id["off"]["state"] == "disabled"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_web_app_status_exposes_configured_channel(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"workspace": str(workspace), "model": "openai/gpt-5"}},
|
||||
"providers": {},
|
||||
"channels": {
|
||||
"webhook-dev": {
|
||||
"enabled": True,
|
||||
"kind": "webhook",
|
||||
"mode": "webhook",
|
||||
"accountId": "local",
|
||||
"displayName": "Webhook Dev",
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
service = AgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
with TestClient(app) as client:
|
||||
payload = client.get("/api/status").json()
|
||||
|
||||
service.close()
|
||||
assert payload["channels"][0]["channel_id"] == "webhook-dev"
|
||||
assert payload["channels"][0]["state"] == "running"
|
||||
assert payload["channels"][0]["webhook_url"] == "/api/channels/webhook-dev/webhook"
|
||||
assert payload["runtime_controls"]["self_restart"] is True
|
||||
|
||||
|
||||
def test_self_restart_env_defaults_enabled(monkeypatch) -> None:
|
||||
monkeypatch.delenv("BEAVER_ENABLE_SELF_RESTART", raising=False)
|
||||
|
||||
assert _self_restart_enabled() is True
|
||||
|
||||
|
||||
def test_self_restart_env_can_disable(monkeypatch) -> None:
|
||||
monkeypatch.setenv("BEAVER_ENABLE_SELF_RESTART", "0")
|
||||
|
||||
assert _self_restart_enabled() is False
|
||||
Reference in New Issue
Block a user