244 lines
9.6 KiB
Python
244 lines
9.6 KiB
Python
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()
|