feat: implement channel runtime connectors
This commit is contained in:
@ -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()
|
||||
Reference in New Issue
Block a user