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