feat: implement channel runtime connectors

This commit is contained in:
2026-06-03 16:22:44 +08:00
parent ee972441f5
commit c3d84b904a
105 changed files with 15621 additions and 322 deletions

View File

@ -0,0 +1,84 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService
def test_channel_connection_api_creates_updates_lists_and_revokes(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
try:
with TestClient(app) as client:
created = client.post(
"/api/channel-connections",
json={
"kind": "telegram",
"mode": "polling",
"displayName": "Telegram Main",
"authType": "token",
"secrets": {"botToken": "token-1"},
"config": {"maxMessageChars": 4096, "requireMentionInGroups": True},
},
)
assert created.status_code == 200
body = created.json()
connection_id = body["connection"]["connection_id"]
assert body["connection"]["kind"] == "telegram"
assert body["connection"]["status"] == "draft"
assert "credentials_ref" not in body["connection"]
assert body["connection"]["runtime_config"] == {
"max_message_chars": 4096,
"require_mention_in_groups": True,
}
assert body["credentials"] == {"botToken": "***"}
patched = client.patch(
f"/api/channel-connections/{connection_id}",
json={
"displayName": "Telegram Ops",
"config": {"maxMessageChars": 2048},
"secrets": {"botToken": "token-2"},
},
)
assert patched.status_code == 200
assert patched.json()["connection"]["display_name"] == "Telegram Ops"
assert patched.json()["connection"]["runtime_config"] == {"max_message_chars": 2048}
assert patched.json()["credentials"] == {"botToken": "***"}
listed = client.get("/api/channel-connections")
assert listed.status_code == 200
assert listed.json()[0]["connection_id"] == connection_id
assert "credentials_ref" not in listed.json()[0]
revoked = client.post(f"/api/channel-connections/{connection_id}/revoke")
assert revoked.status_code == 200
assert revoked.json()["connection"]["status"] == "revoked"
finally:
service.close()
def test_channel_connectors_api_lists_registered_connectors(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
try:
with TestClient(app) as client:
response = client.get("/api/channel-connectors")
finally:
service.close()
assert response.status_code == 200
assert response.json() == [{"kind": "feishu"}, {"kind": "telegram"}, {"kind": "weixin"}]

View File

@ -0,0 +1,63 @@
from __future__ import annotations
from beaver.interfaces.channels.connections import (
ChannelConnectionStore,
CredentialStore,
PairingTokenStore,
)
def test_channel_connection_store_creates_updates_lists_and_revokes(tmp_path) -> None:
store = ChannelConnectionStore(tmp_path / "connections.json")
created = store.create(
kind="telegram",
mode="polling",
display_name="Telegram Main",
account_id="telegram:bot-main",
owner_user_id="user-1",
auth_type="token",
runtime_config={"max_message_chars": 4096},
capabilities=["receive_text", "send_text"],
)
updated = store.update_status(created.connection_id, status="connected", last_error=None)
revoked = store.revoke(created.connection_id)
assert created.connection_id
assert created.channel_id.startswith("telegram-")
assert created.status == "draft"
assert updated.status == "connected"
assert revoked.status == "revoked"
assert store.get(created.connection_id).status == "revoked"
assert [item.connection_id for item in store.list()] == [created.connection_id]
def test_credential_store_saves_values_by_reference_and_redacts_views(tmp_path) -> None:
store = CredentialStore(tmp_path / "credentials.json")
ref = store.put(kind="telegram", values={"botToken": "secret-token", "empty": ""})
assert ref.startswith("cred_")
assert store.get(ref) == {"botToken": "secret-token"}
assert store.redacted(ref) == {"botToken": "***"}
def test_pairing_token_store_uses_one_time_expiring_tokens(tmp_path) -> None:
store = PairingTokenStore(tmp_path / "pairing.json")
session = store.create(kind="terminal", ttl_seconds=60, scope="channel:pair")
consumed = store.consume(session.token, expected_kind="terminal")
reused = store.consume(session.token, expected_kind="terminal")
assert session.status == "pending"
assert consumed is not None
assert consumed.status == "consumed"
assert reused is None
def test_pairing_token_store_rejects_expired_tokens(tmp_path) -> None:
store = PairingTokenStore(tmp_path / "pairing.json")
session = store.create(kind="weixin", ttl_seconds=-1, scope="channel:pair")
assert store.consume(session.token, expected_kind="weixin") is None

View File

@ -0,0 +1,164 @@
from __future__ import annotations
import asyncio
from beaver.foundation.config.schema import ChannelConfig
from beaver.interfaces.channels.connections import (
ChannelConnectionStore,
ChannelConnectorRegistry,
ChannelRuntimeSpec,
CredentialStore,
ValidationResult,
)
class FakeConnector:
kind = "fake"
def __init__(self) -> None:
self.validated: list[str] = []
self.revoked: list[str] = []
async def validate(self, connection_id: str) -> ValidationResult:
self.validated.append(connection_id)
return ValidationResult(ok=True, status="connected", account_id="fake-account", display_name="Fake")
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
return ChannelRuntimeSpec(
channel_id="fake-channel",
kind="fake",
mode="webhook",
account_id="fake-account",
display_name="Fake",
config={"enabled": True},
)
async def revoke(self, connection_id: str) -> None:
self.revoked.append(connection_id)
return None
def test_connector_registry_dispatches_by_kind(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
connector = FakeConnector()
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(connector)
connection = connection_store.create(
kind="fake",
mode="webhook",
display_name="Fake",
account_id="fake-account",
owner_user_id=None,
auth_type="token",
)
result = await registry.validate(connection.connection_id)
spec = await registry.materialize_runtime(connection.connection_id)
assert result.ok is True
assert connector.validated == [connection.connection_id]
assert spec.channel_id == "fake-channel"
asyncio.run(run())
def test_connector_registry_materializes_channel_configs_with_credentials(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"})
connection = connection_store.create(
kind="fake",
mode="webhook",
display_name="Connected",
account_id="connected",
owner_user_id=None,
auth_type="token",
credentials_ref=credentials_ref,
)
connection_store.update_status(connection.connection_id, status="connected", last_error=None)
class CredentialAwareConnector(FakeConnector):
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
stored = connection_store.get(connection_id)
return ChannelRuntimeSpec(
channel_id="fake-channel",
kind="fake",
mode="webhook",
account_id="fake-account",
display_name="Fake",
config={"enabled": True},
secrets_ref=stored.credentials_ref,
)
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(CredentialAwareConnector())
configs = await registry.materialize_channel_configs()
assert isinstance(configs["fake-channel"], ChannelConfig)
assert configs["fake-channel"].enabled is True
assert configs["fake-channel"].secrets == {"botToken": "token-1"}
asyncio.run(run())
def test_connector_registry_materializes_only_connected_connections(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(FakeConnector())
draft = connection_store.create(
kind="fake",
mode="webhook",
display_name="Draft",
account_id="draft",
owner_user_id=None,
auth_type="token",
)
connected = connection_store.create(
kind="fake",
mode="webhook",
display_name="Connected",
account_id="connected",
owner_user_id=None,
auth_type="token",
)
connection_store.update_status(connected.connection_id, status="connected", last_error=None)
specs = await registry.materialize_connected_runtime_specs()
assert [spec.channel_id for spec in specs] == ["fake-channel"]
assert connection_store.get(draft.connection_id).status == "draft"
asyncio.run(run())
def test_connector_registry_revoke_calls_connector_and_updates_store(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
connector = FakeConnector()
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(connector)
connection = connection_store.create(
kind="fake",
mode="webhook",
display_name="Fake",
account_id="fake-account",
owner_user_id=None,
auth_type="token",
)
connection_store.update_status(connection.connection_id, status="connected", last_error=None)
await registry.revoke(connection.connection_id)
assert connector.revoked == [connection.connection_id]
assert connection_store.get(connection.connection_id).status == "revoked"
asyncio.run(run())

View 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

View File

@ -0,0 +1,119 @@
from __future__ import annotations
import asyncio
from beaver.foundation.config.schema import ChannelConfig
from beaver.foundation.events import MessageBus, OutboundMessage
from beaver.interfaces.channels.runtime import ChannelRuntime
class FakeService:
async def handle_inbound_message(self, inbound):
return OutboundMessage(channel=inbound.channel, content="ok", session_id=inbound.session_id, finish_reason="stop")
def test_runtime_add_channel_starts_new_channel_after_runtime_start(tmp_path) -> None:
async def run() -> None:
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
await runtime.start()
try:
await runtime.add_channel(
"webhook-dev",
ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct"),
)
assert "webhook-dev" in runtime.adapters
assert runtime.states["webhook-dev"]["state"] == "running"
finally:
await runtime.stop()
asyncio.run(run())
def test_runtime_add_channel_noops_for_same_config(tmp_path) -> None:
async def run() -> None:
cfg = ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct")
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
await runtime.start()
try:
await runtime.add_channel("webhook-dev", cfg)
first = runtime.adapters["webhook-dev"]
await runtime.add_channel("webhook-dev", cfg)
assert runtime.adapters["webhook-dev"] is first
finally:
await runtime.stop()
asyncio.run(run())
def test_runtime_replacement_failure_keeps_old_channel(tmp_path) -> None:
async def run() -> None:
good = ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct")
bad = ChannelConfig(enabled=True, kind="missing", mode="http", account_id="acct")
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
await runtime.start()
try:
await runtime.add_channel("webhook-dev", good)
old = runtime.adapters["webhook-dev"]
try:
await runtime.add_channel("webhook-dev", bad)
except ValueError:
pass
else:
raise AssertionError("Expected ValueError")
assert runtime.adapters["webhook-dev"] is old
assert runtime.channel_configs["webhook-dev"] == good
assert runtime.states["webhook-dev"]["state"] == "running"
finally:
await runtime.stop()
asyncio.run(run())
def test_runtime_remove_channel_stops_and_unregisters(tmp_path) -> None:
async def run() -> None:
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
await runtime.start()
try:
await runtime.add_channel(
"webhook-dev",
ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct"),
)
await runtime.remove_channel("webhook-dev")
assert "webhook-dev" not in runtime.adapters
assert "webhook-dev" not in runtime.manager.channels
assert runtime.states["webhook-dev"]["state"] == "removed"
finally:
await runtime.stop()
asyncio.run(run())
def test_runtime_builds_external_connector_channel(tmp_path, monkeypatch) -> None:
async def run() -> None:
monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token")
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
await runtime.start()
try:
await runtime.add_channel(
"weixin-main",
ChannelConfig(
enabled=True,
kind="external_connector",
mode="http",
account_id="weixin:me",
display_name="Weixin Main",
config={
"platformKind": "weixin",
"connectionId": "conn_1",
"sidecarBaseUrl": "http://external-connector:8787",
},
),
)
adapter = runtime.adapters["weixin-main"]
assert adapter.kind == "external_connector"
assert adapter.mode == "http"
assert getattr(adapter, "platform_kind") == "weixin"
finally:
await runtime.stop()
asyncio.run(run())

View File

@ -1,4 +1,5 @@
import json
import asyncio
from fastapi.testclient import TestClient
@ -46,6 +47,44 @@ def test_load_config_reads_current_instance_shape(tmp_path) -> None:
assert target["extra_headers"] == {"X-Test": "1"}
def test_config_loader_reads_channels(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"model": "openai/gpt-5"}},
"channels": {
"webhook-dev": {
"enabled": True,
"kind": "webhook",
"mode": "webhook",
"accountId": "local",
"displayName": "Webhook Dev",
"config": {
"responseTimeoutSeconds": 1800,
"dedupeRetentionHours": 48,
},
"secrets": {"ignored_for_status": "secret-value"},
}
},
}
),
encoding="utf-8",
)
config = load_config(config_path=config_path)
channel = config.channels["webhook-dev"]
assert channel.enabled is True
assert channel.kind == "webhook"
assert channel.mode == "webhook"
assert channel.account_id == "local"
assert channel.display_name == "Webhook Dev"
assert channel.config["response_timeout_seconds"] == 1800
assert channel.config["dedupe_retention_hours"] == 48
assert channel.secrets == {"ignored_for_status": "secret-value"}
def test_provider_resolution_ignores_custom_and_disabled_overrides(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
@ -163,6 +202,58 @@ def test_reload_agent_config_updates_booted_loop_config(tmp_path) -> None:
service.close()
def test_reload_agent_config_keeps_running_service_when_old_mcp_close_fails(tmp_path) -> None:
async def run_case() -> None:
workspace = tmp_path / "workspace"
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace), "model": "old-model"}},
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://old.example.com/v1"}},
}
),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
await service.start()
class FailingMCPManager:
async def close(self) -> None:
raise RuntimeError("Attempted to exit cancel scope in a different task than it was entered in")
loaded = service.create_loop().boot()
loaded.mcp_manager = FailingMCPManager()
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace), "model": "new-model"}},
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://new.example.com/v1"}},
}
),
encoding="utf-8",
)
loop = asyncio.get_running_loop()
unhandled: list[dict[str, object]] = []
previous_handler = loop.get_exception_handler()
loop.set_exception_handler(lambda _loop, context: unhandled.append(context))
try:
_reload_agent_config(service, config_path)
await asyncio.sleep(0)
target = service.create_loop().boot().config.resolve_provider_target()
assert service.is_running is True
assert target["model"] == "new-model"
assert target["api_base"] == "https://new.example.com/v1"
assert unhandled == []
finally:
loop.set_exception_handler(previous_handler)
await service.shutdown(force=True)
asyncio.run(run_case())
def test_agent_defaults_include_runtime_controls(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
@ -245,6 +336,67 @@ def test_agent_config_api_accepts_zero_temperature_and_iterations(tmp_path) -> N
service.close()
def test_channel_config_api_persists_and_masks_secrets(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"model": "openai/gpt-5"}},
"channels": {
"telegram-main": {
"enabled": False,
"kind": "telegram",
"mode": "polling",
"accountId": "bot-main",
"displayName": "Telegram Main",
"secrets": {"botToken": "1234567890abcdef"},
"config": {"requireMentionInGroups": True},
}
},
}
),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
before = client.get("/api/channels/telegram-main/config")
response = client.post(
"/api/channels/telegram-main/config",
json={
"enabled": True,
"kind": "telegram",
"mode": "polling",
"account_id": "bot-main",
"display_name": "Telegram Primary",
"secrets": {"botToken": ""},
"config": {
"requireMentionInGroups": False,
"allowFrom": ["1001", "1002"],
"maxMessageChars": 3000,
},
},
)
saved = json.loads(config_path.read_text(encoding="utf-8"))
channel = saved["channels"]["telegram-main"]
assert before.status_code == 200
assert before.json()["secrets"] == {"botToken": "1234••••cdef"}
assert response.status_code == 200
assert response.json()["ok"] is True
assert response.json()["restart_required"] is True
assert response.json()["channel"]["display_name"] == "Telegram Primary"
assert response.json()["channel"]["secrets"] == {"botToken": "1234••••cdef"}
assert channel["enabled"] is True
assert channel["displayName"] == "Telegram Primary"
assert channel["secrets"]["botToken"] == "1234567890abcdef"
assert channel["config"]["allowFrom"] == ["1001", "1002"]
assert load_config(config_path=config_path).channels["telegram-main"].enabled is True
service.close()
def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
bundle = make_provider_bundle(
model="qwen-plus",

View File

@ -0,0 +1,51 @@
from __future__ import annotations
from beaver.interfaces.channels.connections import MessageDedupeStore
def test_message_dedupe_store_completes_and_dedupes_completed(tmp_path) -> None:
store = MessageDedupeStore(tmp_path / "message_dedupe.json")
first = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
store.complete(first.dedupe_key, message_id="msg_1")
duplicate = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)
assert first.should_process is True
assert duplicate.should_process is False
assert duplicate.status == "completed"
assert duplicate.http_status == 200
def test_message_dedupe_store_returns_conflict_for_active_processing(tmp_path) -> None:
store = MessageDedupeStore(tmp_path / "message_dedupe.json", processing_ttl_seconds=60)
store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
duplicate = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)
assert duplicate.should_process is False
assert duplicate.status == "processing"
assert duplicate.http_status == 409
assert duplicate.retry_after_seconds == 5
def test_message_dedupe_store_reprocesses_stale_processing(tmp_path) -> None:
store = MessageDedupeStore(tmp_path / "message_dedupe.json", processing_ttl_seconds=0)
store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
stale = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)
assert stale.should_process is True
assert stale.status == "processing"
assert stale.record.delivery_attempts == 2
def test_message_dedupe_store_reprocesses_failed_records(tmp_path) -> None:
store = MessageDedupeStore(tmp_path / "message_dedupe.json")
first = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
store.fail(first.dedupe_key, error="runtime rejected")
retry = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)
assert retry.should_process is True
assert retry.record.delivery_attempts == 2
assert retry.record.last_error is None

View File

@ -0,0 +1,107 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from beaver.interfaces.channels.connections import ChannelConnectionStore
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService
def _app(tmp_path, monkeypatch):
monkeypatch.setenv("BEAVER_BRIDGE_TOKEN", "bridge-token")
config_path = tmp_path / "config.json"
config_path.write_text(
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
return app, service
def _connected_connection(tmp_path):
state_dir = tmp_path / "state" / "channel_connections"
store = ChannelConnectionStore(state_dir / "connections.json")
connection = store.create(
kind="weixin",
mode="sidecar",
display_name="Weixin Main",
account_id="weixin:me",
owner_user_id=None,
auth_type="connector_session",
)
store.update_status(connection.connection_id, status="connected", last_error=None)
return connection
def _payload(connection, *, event_id: str = "evt-1", delivery_attempt: int = 1) -> dict:
return {
"eventId": event_id,
"timestamp": "2026-06-02T09:30:00Z",
"deliveryAttempt": delivery_attempt,
"connectionId": connection.connection_id,
"channelId": connection.channel_id,
"kind": "weixin",
"accountId": "weixin:me",
"peerId": "peer-1",
"peerType": "dm",
"userId": "sender-1",
"threadId": None,
"messageId": "msg-1",
"messageType": "text",
"content": "hello",
"metadata": {},
}
def test_bridge_endpoint_accepts_valid_event(tmp_path, monkeypatch) -> None:
app, service = _app(tmp_path, monkeypatch)
try:
with TestClient(app) as client:
connection = _connected_connection(tmp_path)
response = client.post(
"/api/channel-connector-bridge/events",
headers={"Authorization": "Bearer bridge-token"},
json=_payload(connection),
)
assert response.status_code == 200
assert response.json()["accepted"] is True
finally:
service.close()
def test_bridge_endpoint_rejects_invalid_token(tmp_path, monkeypatch) -> None:
app, service = _app(tmp_path, monkeypatch)
try:
with TestClient(app) as client:
connection = _connected_connection(tmp_path)
response = client.post(
"/api/channel-connector-bridge/events",
headers={"Authorization": "Bearer wrong"},
json=_payload(connection),
)
assert response.status_code == 401
finally:
service.close()
def test_bridge_endpoint_dedupes_repeated_event(tmp_path, monkeypatch) -> None:
app, service = _app(tmp_path, monkeypatch)
try:
with TestClient(app) as client:
connection = _connected_connection(tmp_path)
first = client.post(
"/api/channel-connector-bridge/events",
headers={"Authorization": "Bearer bridge-token"},
json=_payload(connection),
)
second = client.post(
"/api/channel-connector-bridge/events",
headers={"Authorization": "Bearer bridge-token"},
json=_payload(connection, delivery_attempt=2),
)
assert first.status_code == 200
assert second.status_code in {200, 409}
assert second.json()["duplicate"] is True
finally:
service.close()

View File

@ -0,0 +1,114 @@
from __future__ import annotations
import asyncio
from beaver.foundation.events import ChannelIdentity, OutboundMessage
from beaver.interfaces.channels.external_connector import ExternalConnectorChannel, _request_id
class FakeSidecarClient:
def __init__(self) -> None:
self.sent: list[dict] = []
async def send(self, payload: dict) -> dict:
self.sent.append(payload)
return {"ok": True, "providerMessageId": "provider-1"}
def test_external_connector_channel_sends_with_target_and_request_id() -> None:
async def run() -> None:
client = FakeSidecarClient()
channel = ExternalConnectorChannel(
channel_id="weixin-main",
platform_kind="weixin",
connection_id="conn_1",
account_id="weixin:me",
display_name="Weixin Main",
sidecar_client=client,
)
message = OutboundMessage(
channel="weixin-main",
content="reply",
session_id="s1",
finish_reason="stop",
message_id="out-msg-1",
channel_identity=ChannelIdentity(
channel_id="weixin-main",
kind="weixin",
account_id="weixin:me",
peer_id="peer-1",
peer_type="dm",
thread_id=None,
user_id="sender-1",
message_id="in-msg-1",
),
metadata={"inbound_metadata": {"contextToken": "ctx-1"}},
)
await channel.send(message)
assert client.sent == [
{
"requestId": "out_weixin-main:s1:out-msg-1",
"connectionId": "conn_1",
"channelId": "weixin-main",
"kind": "weixin",
"target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
"content": "reply",
"metadata": {"inboundMessageId": "in-msg-1", "sessionId": "s1", "contextToken": "ctx-1"},
}
]
asyncio.run(run())
def test_external_connector_request_id_falls_back_when_message_id_is_none_or_blank() -> None:
identity = ChannelIdentity(
channel_id="weixin-main",
kind="weixin",
account_id="weixin:me",
peer_id="peer-1",
peer_type="dm",
message_id="in-msg-1",
)
first = OutboundMessage(
channel="weixin-main",
content="same reply",
session_id="s1",
finish_reason="stop",
message_id=None, # type: ignore[arg-type]
channel_identity=identity,
)
second = OutboundMessage(
channel="weixin-main",
content="same reply",
session_id="s1",
finish_reason="stop",
message_id="",
channel_identity=identity,
)
assert _request_id(first) == _request_id(second)
assert _request_id(first).startswith("out_weixin-main:s1:")
def test_external_connector_channel_requires_identity() -> None:
async def run() -> None:
channel = ExternalConnectorChannel(
channel_id="weixin-main",
platform_kind="weixin",
connection_id="conn_1",
account_id="weixin:me",
display_name="Weixin Main",
sidecar_client=FakeSidecarClient(),
)
message = OutboundMessage(channel="weixin-main", content="reply", session_id="s1", finish_reason="stop")
try:
await channel.send(message)
except ValueError as exc:
assert "channel_identity is required" in str(exc)
else:
raise AssertionError("Expected ValueError")
asyncio.run(run())

View File

@ -0,0 +1,176 @@
from __future__ import annotations
import asyncio
from fastapi.testclient import TestClient
from beaver.interfaces.channels.connections import (
ChannelConnectionStore,
ChannelConnectorRegistry,
CredentialStore,
FeishuConnector,
WeixinConnector,
)
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService
class FakeSidecarClient:
def __init__(self) -> None:
self.sessions: dict[str, dict] = {}
self.started: list[dict] = []
self.logged_out: list[str] = []
async def start_session(self, payload: dict) -> dict:
self.started.append(payload)
session = {
"sessionId": "cs_1",
"kind": payload["kind"],
"status": "qr_ready",
"qrImage": "data:image/png;base64,abc",
"accountId": None,
"displayName": None,
"metadata": {},
}
self.sessions["cs_1"] = session
return session
async def get_session(self, session_id: str) -> dict:
return self.sessions[session_id]
async def logout(self, connection_id: str) -> dict:
self.logged_out.append(connection_id)
return {"ok": True}
def test_weixin_connector_starts_connector_session(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
client = FakeSidecarClient()
connector = WeixinConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=client,
sidecar_base_url="http://external-connector:8787",
)
view = await connector.start_session(display_name="Weixin Main", owner_user_id="user-1", options={})
assert view["sessionId"] == "cs_1"
assert view["connectionId"].startswith("conn_")
assert client.started[0]["kind"] == "weixin"
assert client.started[0]["connectionId"].startswith("conn_")
assert connection_store.list()[0].kind == "weixin"
assert connection_store.list()[0].status == "pairing"
asyncio.run(run())
def test_weixin_connector_poll_connected_materializes_external_runtime(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
client = FakeSidecarClient()
connector = WeixinConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=client,
sidecar_base_url="http://external-connector:8787",
)
await connector.start_session(display_name="Weixin Main", owner_user_id=None, options={})
connection = connection_store.list()[0]
client.sessions["cs_1"] = {
"sessionId": "cs_1",
"kind": "weixin",
"status": "connected",
"accountId": "weixin:me",
"displayName": "Me",
"metadata": {"stateRef": "state-1"},
}
result = await connector.poll_session("cs_1")
updated = connection_store.get(connection.connection_id)
spec = await connector.materialize_runtime(connection.connection_id)
assert result["status"] == "connected"
assert updated.status == "connected"
assert updated.account_id == "weixin:me"
assert spec.kind == "external_connector"
assert spec.mode == "http"
assert spec.config["platformKind"] == "weixin"
assert spec.config["sidecarBaseUrl"] == "http://external-connector:8787"
asyncio.run(run())
def test_feishu_connector_uses_feishu_kind(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
client = FakeSidecarClient()
connector = FeishuConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=client,
sidecar_base_url="http://external-connector:8787",
)
await connector.start_session(display_name="Feishu Main", owner_user_id=None, options={"domain": "feishu"})
assert client.started[0]["kind"] == "feishu"
assert client.started[0]["options"] == {"domain": "feishu"}
asyncio.run(run())
def test_connector_session_api_starts_and_polls_connected_session(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token")
config_path = tmp_path / "config.json"
config_path.write_text(
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
client = FakeSidecarClient()
try:
with TestClient(app) as http:
state_dir = tmp_path / "state" / "channel_connections"
connection_store = ChannelConnectionStore(state_dir / "connections.json")
credential_store = CredentialStore(state_dir / "credentials.json")
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(
WeixinConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=client,
sidecar_base_url="http://external-connector:8787",
)
)
app.state.channel_connector_registry = registry
started = http.post(
"/api/channel-connector-sessions",
json={"kind": "weixin", "displayName": "Weixin Main", "options": {}},
)
session_id = started.json()["session"]["sessionId"]
connection_id = started.json()["connection"]["connection_id"]
client.sessions[session_id] = {
"sessionId": session_id,
"kind": "weixin",
"status": "connected",
"accountId": "weixin:me",
"displayName": "Me",
"metadata": {},
}
polled = http.get(f"/api/channel-connector-sessions/{session_id}")
assert started.status_code == 200
assert polled.status_code == 200
assert polled.json()["connection"]["status"] == "connected"
assert connection_store.get(connection_id).status == "connected"
assert polled.json()["connection"]["channel_id"] in app.state.channel_runtime.adapters
finally:
service.close()

View File

@ -0,0 +1,154 @@
import asyncio
from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.platforms.feishu import FeishuAdapter
class FakeSink:
def __init__(self) -> None:
self.messages = []
async def accept_inbound(self, message):
self.messages.append(message)
class FakeFeishuClient:
def __init__(self) -> None:
self.sent = []
async def send_text(self, *, receive_id_type: str, receive_id: str, text: str):
self.sent.append({"receive_id_type": receive_id_type, "receive_id": receive_id, "text": text})
def test_feishu_normalizes_direct_text_event() -> None:
async def run() -> None:
sink = FakeSink()
adapter = FeishuAdapter(
channel_id="feishu-main",
kind="feishu",
mode="websocket",
account_id="tenant-main",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "appSecret": "secret"},
config={},
client=FakeFeishuClient(),
)
await adapter.handle_event_payload(
{
"event": {
"message": {
"message_id": "m1",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"content": "{\"text\":\"hello\"}",
},
"sender": {"sender_id": {"open_id": "ou_user"}},
}
}
)
message = sink.messages[0]
assert message.content == "hello"
assert message.session_id == "feishu-main:tenant-main:oc_chat"
assert message.channel_identity.peer_type == "dm"
assert message.channel_identity.user_id == "ou_user"
asyncio.run(run())
def test_feishu_group_mention_gate() -> None:
async def run() -> None:
sink = FakeSink()
adapter = FeishuAdapter(
channel_id="feishu-main",
kind="feishu",
mode="websocket",
account_id="tenant-main",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "appSecret": "secret"},
config={"requireMentionInGroups": True, "botOpenId": "ou_bot"},
client=FakeFeishuClient(),
)
await adapter.handle_event_payload(
{
"event": {
"message": {
"message_id": "m1",
"chat_id": "oc_group",
"chat_type": "group",
"message_type": "text",
"content": "{\"text\":\"hello\"}",
"mentions": [],
},
"sender": {"sender_id": {"open_id": "ou_user"}},
}
}
)
await adapter.handle_event_payload(
{
"event": {
"message": {
"message_id": "m2",
"chat_id": "oc_group",
"chat_type": "group",
"message_type": "text",
"content": "{\"text\":\"hello\"}",
"mentions": [{"id": {"open_id": "ou_bot"}}],
},
"sender": {"sender_id": {"open_id": "ou_user"}},
}
}
)
assert len(sink.messages) == 1
asyncio.run(run())
def test_feishu_sends_text_to_chat_id() -> None:
async def run() -> None:
sink = FakeSink()
client = FakeFeishuClient()
adapter = FeishuAdapter(
channel_id="feishu-main",
kind="feishu",
mode="websocket",
account_id="tenant-main",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "appSecret": "secret"},
config={},
client=client,
)
await adapter.handle_event_payload(
{
"event": {
"message": {
"message_id": "m1",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"content": "{\"text\":\"hello\"}",
},
"sender": {"sender_id": {"open_id": "ou_user"}},
}
}
)
await adapter.send(
OutboundMessage(
channel="feishu-main",
content="ok",
session_id=sink.messages[0].session_id,
finish_reason="stop",
channel_identity=sink.messages[0].channel_identity,
)
)
assert client.sent == [{"receive_id_type": "chat_id", "receive_id": "oc_chat", "text": "ok"}]
asyncio.run(run())

View File

@ -2,9 +2,10 @@ import asyncio
from dataclasses import dataclass, field
from typing import Any
from beaver.foundation.events import InboundMessage, MessageBus
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
from beaver.interfaces.channels import ChannelManager, MemoryChannelAdapter
from beaver.interfaces.gateway.main import run_gateway
from beaver.interfaces.channels.runtime import ChannelRuntime
from beaver.services.agent_service import AgentService
@ -52,22 +53,15 @@ class InvalidService:
is_running = True
def test_gateway_routes_memory_channel_roundtrip() -> None:
def test_gateway_routes_memory_channel_roundtrip(tmp_path) -> None:
async def run() -> None:
bus = MessageBus()
channel = MemoryChannelAdapter(bus)
stop_event = asyncio.Event()
task = asyncio.create_task(
run_gateway(
service=FakeService(),
manage_service_lifecycle=False,
bus=bus,
channels=[channel],
stop_event=stop_event,
)
)
runtime = ChannelRuntime(service=FakeService(), bus=bus, channels={}, workspace=tmp_path)
channel = MemoryChannelAdapter(runtime)
runtime.manager.register(channel)
await runtime.start()
await channel.publish_text("hello", session_id="s1")
await channel.publish_text("hello", peer_id="s1", message_id="m1")
for _ in range(40):
if channel.sent_messages:
break
@ -76,38 +70,73 @@ def test_gateway_routes_memory_channel_roundtrip() -> None:
assert channel.sent_messages
message = channel.sent_messages[0]
assert message.content == "echo:hello"
assert message.session_id == "s1"
assert message.session_id == "memory-dev:memory:s1"
assert message.finish_reason == "stop"
assert message.metadata["task_id"] == "task-1"
assert message.metadata["task_status"] == "awaiting_acceptance"
assert message.metadata["evidence_status"] == "recorded"
assert message.metadata["validation_result"] is None
stop_event.set()
await asyncio.wait_for(task, timeout=2)
await runtime.stop()
asyncio.run(run())
def test_gateway_delivers_cancelled_outbound_to_channel() -> None:
def test_channel_manager_dispatches_by_channel_id() -> None:
class CaptureChannel:
channel_id = "webhook-dev"
kind = "webhook"
mode = "webhook"
def __init__(self) -> None:
self.sent = []
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def send(self, message: Any) -> None:
self.sent.append(message)
async def run() -> None:
bus = MessageBus()
channel = MemoryChannelAdapter(bus)
stop_event = asyncio.Event()
task = asyncio.create_task(
run_gateway(
service=SlowService(),
manage_service_lifecycle=False,
bus=bus,
channels=[channel],
stop_event=stop_event,
channel = CaptureChannel()
manager = ChannelManager(bus)
manager.register(channel)
await bus.publish_outbound(
OutboundMessage(
channel="webhook-dev",
content="ok",
session_id="webhook-dev:local:demo",
finish_reason="stop",
)
)
await channel.publish_text("slow", session_id="s1")
await asyncio.sleep(0.05)
stop_event = asyncio.Event()
stop_event.set()
await asyncio.wait_for(task, timeout=3)
await manager.dispatch_outbound(stop_event)
assert channel.sent[0].content == "ok"
asyncio.run(run())
def test_gateway_delivers_cancelled_outbound_to_channel(tmp_path) -> None:
async def run() -> None:
bus = MessageBus()
runtime = ChannelRuntime(service=SlowService(), bus=bus, channels={}, workspace=tmp_path)
channel = MemoryChannelAdapter(runtime)
runtime.manager.register(channel)
await runtime.start()
await channel.publish_text("slow", peer_id="s1", message_id="m1")
for _ in range(40):
if any(event["kind"] == "direct_run_started" for event in runtime.events.recent(limit=20)):
break
await asyncio.sleep(0.05)
await runtime.stop()
assert channel.sent_messages
assert channel.sent_messages[0].finish_reason == "cancelled"
@ -118,13 +147,27 @@ def test_gateway_delivers_cancelled_outbound_to_channel() -> None:
def test_gateway_rejects_channel_manager_and_channels_together() -> None:
async def run() -> None:
bus = MessageBus()
class CaptureChannel:
channel_id = "memory-dev"
kind = "memory"
mode = "webhook"
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def send(self, message: Any) -> None:
pass
try:
await run_gateway(
service=FakeService(),
manage_service_lifecycle=False,
bus=bus,
channel_manager=ChannelManager(bus),
channels=[MemoryChannelAdapter(bus)],
channels=[CaptureChannel()],
stop_event=asyncio.Event(),
)
except ValueError as exc:
@ -212,10 +255,16 @@ def test_channel_manager_keeps_unknown_channel_outbound_undeliverable() -> None:
asyncio.run(run())
def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
def test_memory_channel_adapts_payload_to_channel_identity_session_id(tmp_path) -> None:
async def run() -> None:
bus = MessageBus()
channel = MemoryChannelAdapter(bus, name="telegram")
runtime = ChannelRuntime(service=FakeService(), bus=bus, channels={}, workspace=tmp_path)
channel = MemoryChannelAdapter(
runtime,
channel_id="telegram-main",
kind="telegram",
account_id="bot-main",
)
inbound = await channel.publish_external_text(
"hello",
chat_id="chat-1",
@ -225,8 +274,10 @@ def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
queued = await bus.consume_inbound()
assert queued is inbound
assert queued.channel == "telegram"
assert queued.session_id == "telegram:chat-1"
assert queued.channel == "telegram-main"
assert queued.session_id == "telegram-main:bot-main:chat-1"
assert queued.channel_identity is not None
assert queued.channel_identity.kind == "telegram"
assert queued.metadata["chat_id"] == "chat-1"
assert queued.metadata["message_id"] == "message-1"
assert queued.metadata["raw_channel_payload"] == {"platform": "telegram", "text": "hello"}
@ -236,7 +287,9 @@ def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None:
class StartedChannel:
name = "started"
channel_id = "started"
kind = "memory"
mode = "webhook"
def __init__(self, bus: MessageBus) -> None:
self.bus = bus
@ -252,7 +305,9 @@ def test_channel_manager_start_cancellation_rolls_back_started_channels() -> Non
pass
class BlockingChannel:
name = "blocking"
channel_id = "blocking"
kind = "memory"
mode = "webhook"
def __init__(self, bus: MessageBus) -> None:
self.bus = bus

View File

@ -6,6 +6,34 @@ from beaver.interfaces.web.app import create_app
from beaver.interfaces.web.schemas import WebChatRequest, WebChatResponse
def test_platform_channel_modules_import_without_live_clients() -> None:
from beaver.interfaces.channels.platforms.feishu import FeishuAdapter
from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter
from beaver.interfaces.channels.platforms.telegram import TelegramAdapter
from beaver.interfaces.channels.platforms.weixin import WeixinAdapter
assert FeishuAdapter.KIND == "feishu"
assert QQBotAdapter.KIND == "qqbot"
assert TelegramAdapter.KIND == "telegram"
assert WeixinAdapter.KIND == "weixin"
def test_platform_channel_optional_extras_are_declared() -> None:
import tomllib
from pathlib import Path
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
extras = data["project"]["optional-dependencies"]
assert "python-telegram-bot>=22.0,<23.0" in extras["telegram"]
assert "lark-oapi>=1.4.22,<2.0.0" in extras["feishu"]
assert "aiohttp>=3.9.0,<4.0.0" in extras["qqbot"]
assert "aiohttp>=3.9.0,<4.0.0" in extras["weixin"]
assert "python-telegram-bot>=22.0,<23.0" in extras["channels"]
assert "lark-oapi>=1.4.22,<2.0.0" in extras["channels"]
def test_agent_loop_boots(tmp_path) -> None:
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
loaded = loop.boot()
@ -32,10 +60,14 @@ def test_message_bus_imports() -> None:
def test_channel_imports() -> None:
bus = MessageBus()
channel = MemoryChannelAdapter(bus)
class Sink:
async def accept_inbound(self, message):
await bus.publish_inbound(message)
channel = MemoryChannelAdapter(Sink())
manager = ChannelManager(bus)
manager.register(channel)
assert manager.channels["memory"] is channel
assert manager.channels["memory-dev"] is channel
def test_web_schema_imports() -> None:

View File

@ -0,0 +1,66 @@
from beaver.foundation.events import ChannelIdentity, OutboundMessage
from beaver.interfaces.channels.platforms.base import (
chunk_text,
compact_media_summary,
config_bool,
config_list,
outbound_target,
)
def test_config_helpers_normalize_common_values() -> None:
assert config_bool({"enabled": "true"}, "enabled", default=False) is True
assert config_bool({"enabled": "0"}, "enabled", default=True) is False
assert config_list({"allowFrom": "u1,u2"}, "allowFrom") == ["u1", "u2"]
assert config_list({"allowFrom": ["u1", 2]}, "allowFrom") == ["u1", "2"]
def test_chunk_text_preserves_order_and_limit() -> None:
chunks = chunk_text("abcdef", max_chars=2)
assert chunks == ["ab", "cd", "ef"]
def test_outbound_target_prefers_channel_identity() -> None:
identity = ChannelIdentity(
channel_id="telegram-main",
kind="telegram",
account_id="bot-main",
peer_id="chat-1",
thread_id="topic-1",
peer_type="group",
user_id="user-1",
)
message = OutboundMessage(
channel="telegram-main",
content="ok",
session_id="ignored",
finish_reason="stop",
channel_identity=identity,
)
target = outbound_target(message)
assert target.peer_id == "chat-1"
assert target.thread_id == "topic-1"
assert target.peer_type == "group"
assert target.user_id == "user-1"
def test_outbound_target_falls_back_to_session_id() -> None:
message = OutboundMessage(
channel="telegram-main",
content="ok",
session_id="telegram-main:bot-main:chat-1:topic-1",
finish_reason="stop",
)
target = outbound_target(message)
assert target.peer_id == "chat-1"
assert target.thread_id == "topic-1"
def test_compact_media_summary_mentions_attachment_type() -> None:
assert compact_media_summary("photo", file_name="cat.png") == "[photo: cat.png]"
assert compact_media_summary("document") == "[document]"

View File

@ -0,0 +1,143 @@
import asyncio
from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter
class FakeSink:
def __init__(self) -> None:
self.messages = []
async def accept_inbound(self, message):
self.messages.append(message)
class FakeQQBotClient:
def __init__(self) -> None:
self.sent = []
async def send_text(self, *, peer_type: str, peer_id: str, content: str, message_id: str | None):
self.sent.append(
{
"peer_type": peer_type,
"peer_id": peer_id,
"content": content,
"message_id": message_id,
}
)
def test_qqbot_normalizes_private_c2c_message() -> None:
async def run() -> None:
sink = FakeSink()
adapter = QQBotAdapter(
channel_id="qq-main",
kind="qqbot",
mode="websocket",
account_id="qq-bot",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "clientSecret": "secret"},
config={},
client=FakeQQBotClient(),
)
await adapter.handle_event_payload(
{
"t": "C2C_MESSAGE_CREATE",
"d": {
"id": "m1",
"author": {"user_openid": "u1"},
"content": "hello",
},
}
)
message = sink.messages[0]
assert message.content == "hello"
assert message.session_id == "qq-main:qq-bot:u1"
assert message.channel_identity.peer_type == "dm"
assert message.channel_identity.user_id == "u1"
asyncio.run(run())
def test_qqbot_normalizes_group_message() -> None:
async def run() -> None:
sink = FakeSink()
adapter = QQBotAdapter(
channel_id="qq-main",
kind="qqbot",
mode="websocket",
account_id="qq-bot",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "clientSecret": "secret"},
config={},
client=FakeQQBotClient(),
)
await adapter.handle_event_payload(
{
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {
"id": "m2",
"group_openid": "g1",
"author": {"member_openid": "u1"},
"content": "hello group",
},
}
)
message = sink.messages[0]
assert message.session_id == "qq-main:qq-bot:g1"
assert message.channel_identity.peer_type == "group"
assert message.channel_identity.user_id == "u1"
asyncio.run(run())
def test_qqbot_sends_reply_with_original_message_id() -> None:
async def run() -> None:
sink = FakeSink()
client = FakeQQBotClient()
adapter = QQBotAdapter(
channel_id="qq-main",
kind="qqbot",
mode="websocket",
account_id="qq-bot",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "clientSecret": "secret"},
config={},
client=client,
)
await adapter.handle_event_payload(
{
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {
"id": "m2",
"group_openid": "g1",
"author": {"member_openid": "u1"},
"content": "hello group",
},
}
)
await adapter.send(
OutboundMessage(
channel="qq-main",
content="ok",
session_id=sink.messages[0].session_id,
finish_reason="stop",
channel_identity=sink.messages[0].channel_identity,
)
)
assert client.sent[0] == {
"peer_type": "group",
"peer_id": "g1",
"content": "ok",
"message_id": "m2",
}
asyncio.run(run())

View File

@ -0,0 +1,141 @@
import asyncio
from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.platforms.telegram import TelegramAdapter
class FakeSink:
def __init__(self) -> None:
self.messages = []
async def accept_inbound(self, message):
self.messages.append(message)
class FakeTelegramClient:
def __init__(self) -> None:
self.sent = []
async def send_message(self, **kwargs):
self.sent.append(kwargs)
def test_telegram_normalizes_private_text_message() -> None:
async def run() -> None:
sink = FakeSink()
adapter = TelegramAdapter(
channel_id="telegram-main",
kind="telegram",
mode="polling",
account_id="bot-main",
display_name=None,
inbound_sink=sink,
secrets={"botToken": "x"},
config={},
client=FakeTelegramClient(),
)
await adapter.handle_update_payload(
{
"message": {
"message_id": 100,
"text": "hello",
"chat": {"id": 200, "type": "private"},
"from": {"id": 300, "username": "ivan"},
}
}
)
message = sink.messages[0]
assert message.channel == "telegram-main"
assert message.content == "hello"
assert message.session_id == "telegram-main:bot-main:200"
assert message.channel_identity.peer_type == "dm"
assert message.channel_identity.user_id == "300"
assert message.channel_identity.message_id == "100"
asyncio.run(run())
def test_telegram_group_requires_mention_when_configured() -> None:
async def run() -> None:
sink = FakeSink()
adapter = TelegramAdapter(
channel_id="telegram-main",
kind="telegram",
mode="polling",
account_id="bot-main",
display_name=None,
inbound_sink=sink,
secrets={"botToken": "x"},
config={"requireMentionInGroups": True, "botUsername": "beaver_bot"},
client=FakeTelegramClient(),
)
await adapter.handle_update_payload(
{
"message": {
"message_id": 101,
"text": "hello group",
"chat": {"id": -20, "type": "group"},
"from": {"id": 300},
}
}
)
await adapter.handle_update_payload(
{
"message": {
"message_id": 102,
"text": "@beaver_bot hello",
"chat": {"id": -20, "type": "group"},
"from": {"id": 300},
}
}
)
assert len(sink.messages) == 1
assert sink.messages[0].content == "hello"
asyncio.run(run())
def test_telegram_sends_chunked_reply_to_identity_target() -> None:
async def run() -> None:
sink = FakeSink()
client = FakeTelegramClient()
adapter = TelegramAdapter(
channel_id="telegram-main",
kind="telegram",
mode="polling",
account_id="bot-main",
display_name=None,
inbound_sink=sink,
secrets={"botToken": "x"},
config={"maxMessageChars": 3},
client=client,
)
await adapter.handle_update_payload(
{
"message": {
"message_id": 100,
"text": "hello",
"chat": {"id": 200, "type": "private"},
"from": {"id": 300},
}
}
)
await adapter.send(
OutboundMessage(
channel="telegram-main",
content="abcdef",
session_id=sink.messages[0].session_id,
finish_reason="stop",
channel_identity=sink.messages[0].channel_identity,
)
)
assert [item["text"] for item in client.sent] == ["abc", "def"]
assert client.sent[0]["chat_id"] == "200"
asyncio.run(run())

View File

@ -0,0 +1,143 @@
from __future__ import annotations
import asyncio
from beaver.interfaces.channels.connections import (
ChannelConnectionStore,
CredentialStore,
TelegramConnector,
)
class FakeTelegramClient:
async def get_me(self):
return {"id": 12345, "username": "beaver_bot", "first_name": "Beaver"}
class BrokenTelegramClient:
async def get_me(self):
raise RuntimeError("invalid token")
def test_telegram_connector_validates_token_and_updates_connection(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"})
connection = connection_store.create(
kind="telegram",
mode="polling",
display_name="Telegram Main",
account_id="",
owner_user_id="user-1",
auth_type="token",
credentials_ref=credentials_ref,
runtime_config={"max_message_chars": 4096},
)
connector = TelegramConnector(
connection_store=connection_store,
credential_store=credential_store,
client_factory=lambda token: FakeTelegramClient(),
)
result = await connector.validate(connection.connection_id)
updated = connection_store.get(connection.connection_id)
assert result.ok is True
assert result.status == "connected"
assert result.account_id == "telegram:12345"
assert updated.account_id == "telegram:12345"
assert updated.display_name == "Beaver (@beaver_bot)"
assert updated.capabilities == ["receive_text", "send_text", "receive_media", "groups"]
asyncio.run(run())
def test_telegram_connector_materializes_runtime_spec(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"})
connection = connection_store.create(
kind="telegram",
mode="polling",
display_name="Telegram Main",
account_id="telegram:12345",
owner_user_id=None,
auth_type="token",
credentials_ref=credentials_ref,
runtime_config={"max_message_chars": 4096, "require_mention_in_groups": True},
)
connection_store.update_status(connection.connection_id, status="connected", last_error=None)
connector = TelegramConnector(
connection_store=connection_store,
credential_store=credential_store,
client_factory=lambda token: FakeTelegramClient(),
)
spec = await connector.materialize_runtime(connection.connection_id)
assert spec.channel_id == connection.channel_id
assert spec.kind == "telegram"
assert spec.mode == "polling"
assert spec.account_id == "telegram:12345"
assert spec.config["max_message_chars"] == 4096
assert spec.config["require_mention_in_groups"] is True
assert spec.secrets_ref == credentials_ref
asyncio.run(run())
def test_telegram_connector_validation_failure_sets_error_status(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
credentials_ref = credential_store.put(kind="telegram", values={"botToken": "bad-token"})
connection = connection_store.create(
kind="telegram",
mode="polling",
display_name="Telegram Main",
account_id="",
owner_user_id=None,
auth_type="token",
credentials_ref=credentials_ref,
)
connector = TelegramConnector(
connection_store=connection_store,
credential_store=credential_store,
client_factory=lambda token: BrokenTelegramClient(),
)
result = await connector.validate(connection.connection_id)
assert result.ok is False
assert result.status == "error"
assert "invalid token" in (result.error or "")
asyncio.run(run())
def test_telegram_connector_revoke_leaves_store_status_to_registry(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
connection = connection_store.create(
kind="telegram",
mode="polling",
display_name="Telegram Main",
account_id="telegram:12345",
owner_user_id=None,
auth_type="token",
)
connection_store.update_status(connection.connection_id, status="connected", last_error=None)
connector = TelegramConnector(
connection_store=connection_store,
credential_store=credential_store,
client_factory=lambda token: FakeTelegramClient(),
)
await connector.revoke(connection.connection_id)
assert connection_store.get(connection.connection_id).status == "connected"
asyncio.run(run())

View File

@ -0,0 +1,243 @@
import asyncio
import json
import time
from pathlib import Path
from fastapi.testclient import TestClient
from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService
class TerminalFakeAgentService(AgentService):
def __init__(self, *, config_path: Path, delay_seconds: float = 0.0) -> None:
super().__init__(config_path=config_path)
self.delay_seconds = delay_seconds
self.inbound_calls: list[InboundMessage] = []
async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage:
self.inbound_calls.append(inbound)
if self.delay_seconds:
await asyncio.sleep(self.delay_seconds)
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,
)
def write_terminal_config(tmp_path: Path) -> Path:
workspace = tmp_path / "workspace"
workspace.mkdir()
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace), "model": "openai/gpt-5"}},
"providers": {},
"channels": {
"terminal-dev": {
"enabled": True,
"kind": "terminal",
"mode": "websocket",
"accountId": "local",
"displayName": "Terminal Dev",
"config": {"heartbeatSeconds": 30, "maxMessageChars": 20000},
}
},
}
),
encoding="utf-8",
)
return config_path
def test_terminal_websocket_connect_ping_and_message_roundtrip(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json(
{
"type": "connect",
"peer_id": "device-001",
"device_name": "desk-terminal",
"capabilities": ["text"],
}
)
assert websocket.receive_json() == {
"type": "connected",
"channel_id": "terminal-dev",
"session_id": "terminal-dev:local:device-001",
}
websocket.send_json({"type": "ping"})
assert websocket.receive_json() == {"type": "pong"}
websocket.send_json(
{
"type": "message",
"message_id": "device-001-000001",
"text": "hello",
}
)
assert websocket.receive_json() == {
"type": "ack",
"message_id": "device-001-000001",
"session_id": "terminal-dev:local:device-001",
"accepted": True,
}
reply = websocket.receive_json()
service.close()
assert reply == {
"type": "message",
"role": "assistant",
"message_id": "device-001-000001",
"run_id": "run-1",
"text": "echo:hello",
"finish_reason": "stop",
}
assert len(service.inbound_calls) == 1
inbound = service.inbound_calls[0]
assert inbound.channel == "terminal-dev"
assert inbound.content == "hello"
assert inbound.content_type == "text"
assert inbound.session_id == "terminal-dev:local:device-001"
assert inbound.channel_identity is not None
assert inbound.channel_identity.peer_id == "device-001"
assert inbound.channel_identity.peer_type == "terminal"
assert inbound.channel_identity.message_id == "device-001-000001"
def test_terminal_websocket_rejects_message_before_connect(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "message", "message_id": "m1", "text": "hello"})
assert websocket.receive_json() == {
"type": "error",
"error": "connect is required before message",
}
websocket.send_json({"type": "ping"})
assert websocket.receive_json() == {"type": "pong"}
service.close()
assert service.inbound_calls == []
def test_terminal_websocket_unknown_frame_keeps_connection_open(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "example"})
assert websocket.receive_json() == {
"type": "error",
"error": "Unsupported websocket frame type: example",
}
websocket.send_json({"type": "ping"})
assert websocket.receive_json() == {"type": "pong"}
service.close()
def test_terminal_websocket_validates_message_fields(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "connect", "peer_id": "device-001"})
assert websocket.receive_json()["type"] == "connected"
websocket.send_json({"type": "message", "text": "hello"})
assert websocket.receive_json() == {"type": "error", "error": "message_id is required"}
websocket.send_json({"type": "message", "message_id": "m1", "text": " "})
assert websocket.receive_json() == {"type": "error", "error": "text is required"}
service.close()
assert service.inbound_calls == []
def test_terminal_websocket_duplicate_message_returns_cached_reply(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "connect", "peer_id": "device-001"})
assert websocket.receive_json()["type"] == "connected"
frame = {"type": "message", "message_id": "device-001-000001", "text": "hello"}
websocket.send_json(frame)
assert websocket.receive_json()["accepted"] is True
assert websocket.receive_json()["text"] == "echo:hello"
websocket.send_json(frame)
duplicate = websocket.receive_json()
service.close()
assert duplicate["type"] == "ack"
assert duplicate["accepted"] is False
assert duplicate["duplicate"] is True
assert duplicate["pending"] is False
assert duplicate["reply"] == "echo:hello"
assert len(service.inbound_calls) == 1
def test_terminal_websocket_disconnect_before_reply_records_unclaimed(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path, delay_seconds=0.05)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "connect", "peer_id": "device-001"})
assert websocket.receive_json()["type"] == "connected"
websocket.send_json({"type": "message", "message_id": "device-001-000001", "text": "slow"})
assert websocket.receive_json()["accepted"] is True
time.sleep(0.15)
events = client.get("/api/channels/terminal-dev/events").json()
service.close()
kinds = [event["kind"] for event in events]
assert "terminal_disconnected" in kinds
assert "outbound_unclaimed" in kinds
def test_terminal_channel_status_exposes_websocket_url_and_peer_count(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
initial = client.get("/api/status").json()["channels"][0]
assert initial["channel_id"] == "terminal-dev"
assert initial["websocket_url"] == "/api/channels/terminal-dev/ws"
assert initial["connected_peers"] == 0
assert "persistent_connection" in initial["capabilities"]
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "connect", "peer_id": "device-001"})
assert websocket.receive_json()["type"] == "connected"
connected = client.get("/api/status").json()["channels"][0]
assert connected["connected_peers"] == 1
service.close()

View File

@ -0,0 +1,129 @@
import asyncio
from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.platforms.weixin import WeixinAdapter
class FakeSink:
def __init__(self) -> None:
self.messages = []
async def accept_inbound(self, message):
self.messages.append(message)
class FakeWeixinClient:
def __init__(self) -> None:
self.sent = []
async def send_text(self, *, peer_id: str, text: str, context_token: str | None):
self.sent.append({"peer_id": peer_id, "text": text, "context_token": context_token})
def test_weixin_normalizes_direct_text_message() -> None:
async def run() -> None:
sink = FakeSink()
adapter = WeixinAdapter(
channel_id="weixin-main",
kind="weixin",
mode="polling",
account_id="wx-main",
display_name=None,
inbound_sink=sink,
secrets={"token": "token"},
config={},
client=FakeWeixinClient(),
)
await adapter.handle_message_payload(
{
"id": "m1",
"from": "wx_user",
"room_id": "",
"type": "text",
"text": "hello",
"context_token": "ctx1",
}
)
message = sink.messages[0]
assert message.content == "hello"
assert message.session_id == "weixin-main:wx-main:wx_user"
assert message.channel_identity.peer_type == "dm"
assert message.metadata["context_token"] == "ctx1"
asyncio.run(run())
def test_weixin_group_message_is_best_effort() -> None:
async def run() -> None:
sink = FakeSink()
adapter = WeixinAdapter(
channel_id="weixin-main",
kind="weixin",
mode="polling",
account_id="wx-main",
display_name=None,
inbound_sink=sink,
secrets={"token": "token"},
config={"groupPolicy": "open"},
client=FakeWeixinClient(),
)
await adapter.handle_message_payload(
{
"id": "m2",
"from": "wx_user",
"room_id": "room1",
"type": "text",
"text": "hello room",
"context_token": "ctx2",
}
)
message = sink.messages[0]
assert message.session_id == "weixin-main:wx-main:room1"
assert message.channel_identity.peer_type == "group"
assert message.channel_identity.user_id == "wx_user"
asyncio.run(run())
def test_weixin_sends_text_with_context_token() -> None:
async def run() -> None:
sink = FakeSink()
client = FakeWeixinClient()
adapter = WeixinAdapter(
channel_id="weixin-main",
kind="weixin",
mode="polling",
account_id="wx-main",
display_name=None,
inbound_sink=sink,
secrets={"token": "token"},
config={},
client=client,
)
await adapter.handle_message_payload(
{
"id": "m1",
"from": "wx_user",
"type": "text",
"text": "hello",
"context_token": "ctx1",
}
)
await adapter.send(
OutboundMessage(
channel="weixin-main",
content="ok",
session_id=sink.messages[0].session_id,
finish_reason="stop",
channel_identity=sink.messages[0].channel_identity,
metadata={"inbound_metadata": sink.messages[0].metadata},
)
)
assert client.sent == [{"peer_id": "wx_user", "text": "ok", "context_token": "ctx1"}]
asyncio.run(run())