Files
beaver_project/app-instance/backend/tests/unit/test_channel_runtime.py

415 lines
13 KiB
Python

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