415 lines
13 KiB
Python
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
|