Files
beaver_project/docs/superpowers/plans/2026-06-01-terminal-websocket-channel.md

38 KiB

Terminal WebSocket Channel Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a text-only WebSocket channel adapter so a small terminal device can connect to Beaver through the channel runtime and receive final assistant replies.

Architecture: Add TerminalWebSocketAdapter as a channel-native adapter. The FastAPI route /api/channels/{channel_id}/ws delegates WebSocket handling to the adapter, while inbound and outbound traffic still flows through ChannelRuntime, MessageBus, AgentService.handle_inbound_message(), and ChannelManager.dispatch_outbound().

Tech Stack: Python 3.14, FastAPI WebSocket, Beaver ChannelRuntime, Beaver MessageBus, pytest/TestClient, Next.js TypeScript status UI, nginx.


Current Context

This plan assumes channel runtime v1 exists in the working tree:

  • app-instance/backend/beaver/interfaces/channels/runtime.py
  • app-instance/backend/beaver/interfaces/channels/generic_webhook.py
  • app-instance/backend/beaver/interfaces/channels/state.py
  • app-instance/backend/beaver/foundation/events/message_bus.py with ChannelIdentity
  • app-instance/backend/beaver/interfaces/web/app.py with /api/channels/{channel_id}/webhook

Do not route terminal messages to AgentService from the WebSocket route. The adapter must call ChannelRuntime.accept_inbound() and let the runtime bridge publish outbound replies.

Files

  • Create: app-instance/backend/beaver/interfaces/channels/terminal_websocket.py
    • Owns WebSocket connection state, JSON frame validation, connect/message/ping handling, and outbound delivery.
  • Modify: app-instance/backend/beaver/interfaces/channels/runtime.py
    • Instantiate terminal websocket adapters.
    • Include terminal capabilities, websocket URL, and connected peer counts in status.
    • Provide a small event recording callback for adapters.
  • Modify: app-instance/backend/beaver/interfaces/channels/__init__.py
    • Export TerminalWebSocketAdapter.
  • Modify: app-instance/backend/beaver/interfaces/web/app.py
    • Add /api/channels/{channel_id}/ws WebSocket route that delegates to terminal adapters.
  • Modify: app-instance/nginx.conf
    • Add WebSocket upgrade headers to /api/channels/.
  • Modify: app-instance/frontend/types/index.ts
    • Add websocket_url and connected_peers to ChannelStatus.
  • Modify: app-instance/frontend/app/(app)/status/page.tsx
    • Show WebSocket URL and connected peer count in channel details.
  • Test: app-instance/backend/tests/unit/test_terminal_websocket_channel.py
    • Cover connect, ping, message roundtrip, duplicates, errors, status, and disconnect/unclaimed behavior.

Task 1: Add Failing WebSocket Roundtrip Tests

Files:

  • Create: app-instance/backend/tests/unit/test_terminal_websocket_channel.py

  • Step 1: Write failing tests for connect, ping, and message roundtrip

Create app-instance/backend/tests/unit/test_terminal_websocket_channel.py:

import asyncio
import json
from pathlib import Path
from typing import Any

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"
  • Step 2: Run the failing test

Run:

cd app-instance/backend
uv run pytest tests/unit/test_terminal_websocket_channel.py::test_terminal_websocket_connect_ping_and_message_roundtrip -q

Expected: fail because /api/channels/terminal-dev/ws does not exist or terminal/websocket is unsupported.

  • Step 3: Commit the failing test
git add app-instance/backend/tests/unit/test_terminal_websocket_channel.py
git commit -m "test: cover terminal websocket channel roundtrip"

If channel runtime v1 changes are still uncommitted in this working tree, do not include unrelated files in this commit.


Task 2: Implement TerminalWebSocketAdapter

Files:

  • Create: app-instance/backend/beaver/interfaces/channels/terminal_websocket.py

  • Modify: app-instance/backend/beaver/interfaces/channels/__init__.py

  • Step 1: Create the terminal websocket adapter

Create app-instance/backend/beaver/interfaces/channels/terminal_websocket.py:

"""Text-only terminal WebSocket channel adapter."""

from __future__ import annotations

from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass, field
from typing import Any

from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
from beaver.interfaces.channels.base import ChannelInboundSink

try:
    from fastapi import WebSocket
    from starlette.websockets import WebSocketDisconnect
except ModuleNotFoundError:  # pragma: no cover - import-only fallback
    class WebSocketDisconnect(Exception):
        """Fallback disconnect exception for skeleton import environments."""

    class WebSocket:  # type: ignore[override]
        """Fallback websocket annotation shim."""


def _clean(value: Any) -> str:
    return str(value or "").strip()


@dataclass(slots=True)
class TerminalConnection:
    websocket: WebSocket
    peer_id: str
    session_id: str
    thread_id: str | None = None
    user_id: str | None = None
    device_name: str = ""
    capabilities: list[str] = field(default_factory=list)


class TerminalWebSocketAdapter:
    """Accept text terminal websocket frames and deliver final assistant replies."""

    def __init__(
        self,
        *,
        channel_id: str,
        kind: str,
        mode: str,
        account_id: str,
        display_name: str = "",
        inbound_sink: ChannelInboundSink,
        event_recorder: Callable[..., None] | None = None,
        heartbeat_seconds: float = 30,
        max_message_chars: int = 20000,
    ) -> None:
        self.channel_id = channel_id
        self.kind = kind
        self.mode = mode
        self.account_id = account_id
        self.display_name = display_name or channel_id
        self.inbound_sink = inbound_sink
        self.event_recorder = event_recorder
        self.heartbeat_seconds = max(1.0, float(heartbeat_seconds))
        self.max_message_chars = max(1, int(max_message_chars))
        self.started = False
        self._connections_by_session: dict[str, TerminalConnection] = {}
        self._session_by_peer: dict[str, str] = {}

    async def start(self) -> None:
        self.started = True

    async def stop(self) -> None:
        self.started = False
        for connection in list(self._connections_by_session.values()):
            with suppress(Exception):
                await connection.websocket.close(code=1001)
        self._connections_by_session.clear()
        self._session_by_peer.clear()

    def status_extra(self) -> dict[str, Any]:
        return {"connected_peers": len(self._connections_by_session)}

    async def handle_websocket(self, websocket: WebSocket) -> None:
        await websocket.accept()
        connection: TerminalConnection | None = None
        try:
            while True:
                try:
                    payload = await websocket.receive_json()
                except WebSocketDisconnect:
                    break
                except ValueError:
                    await websocket.send_json({"type": "error", "error": "Invalid websocket JSON payload"})
                    continue
                if not isinstance(payload, dict):
                    await websocket.send_json({"type": "error", "error": "Websocket payload must be a JSON object"})
                    continue

                frame_type = _clean(payload.get("type")).lower()
                if frame_type == "ping":
                    await websocket.send_json({"type": "pong"})
                    continue
                if frame_type == "connect":
                    connection = await self._handle_connect(websocket, payload, current=connection)
                    continue
                if frame_type == "message":
                    if connection is None:
                        await websocket.send_json({"type": "error", "error": "connect is required before message"})
                        continue
                    await self._handle_message(websocket, connection, payload)
                    continue

                await websocket.send_json(
                    {
                        "type": "error",
                        "error": f"Unsupported websocket frame type: {frame_type or '<empty>'}",
                    }
                )
        finally:
            if connection is not None:
                self._remove_connection(connection)
                self._record(
                    kind="terminal_disconnected",
                    session_id=connection.session_id,
                    metadata={"peer_id": connection.peer_id, "device_name": connection.device_name},
                )

    async def _handle_connect(
        self,
        websocket: WebSocket,
        payload: dict[str, Any],
        *,
        current: TerminalConnection | None,
    ) -> TerminalConnection | None:
        peer_id = _clean(payload.get("peer_id"))
        if not peer_id:
            await websocket.send_json({"type": "error", "error": "peer_id is required"})
            return current

        thread_id = _clean(payload.get("thread_id")) or None
        user_id = _clean(payload.get("user_id")) or None
        device_name = _clean(payload.get("device_name"))
        capabilities = [str(item) for item in payload.get("capabilities") or [] if item is not None]
        identity = ChannelIdentity(
            channel_id=self.channel_id,
            kind=self.kind,
            account_id=self.account_id,
            peer_id=peer_id,
            thread_id=thread_id,
            peer_type="terminal",
            user_id=user_id,
        )
        session_id = identity.session_id()
        connection = TerminalConnection(
            websocket=websocket,
            peer_id=peer_id,
            session_id=session_id,
            thread_id=thread_id,
            user_id=user_id,
            device_name=device_name,
            capabilities=capabilities,
        )

        if current is not None and current.session_id != session_id:
            self._remove_connection(current)
        old = self._connections_by_session.get(session_id)
        if old is not None and old.websocket is not websocket:
            with suppress(Exception):
                await old.websocket.close(code=1000)
        self._connections_by_session[session_id] = connection
        self._session_by_peer[peer_id] = session_id
        self._record(
            kind="terminal_connected",
            session_id=session_id,
            metadata={"peer_id": peer_id, "device_name": device_name, "capabilities": capabilities},
        )
        await websocket.send_json(
            {
                "type": "connected",
                "channel_id": self.channel_id,
                "session_id": session_id,
            }
        )
        return connection

    async def _handle_message(
        self,
        websocket: WebSocket,
        connection: TerminalConnection,
        payload: dict[str, Any],
    ) -> None:
        message_id = _clean(payload.get("message_id"))
        text = _clean(payload.get("text"))
        if not message_id:
            await websocket.send_json({"type": "error", "error": "message_id is required"})
            return
        if not text:
            await websocket.send_json({"type": "error", "error": "text is required"})
            return
        if len(text) > self.max_message_chars:
            await websocket.send_json(
                {
                    "type": "error",
                    "error": f"text exceeds max_message_chars ({self.max_message_chars})",
                }
            )
            return

        thread_id = _clean(payload.get("thread_id")) or connection.thread_id
        user_id = _clean(payload.get("user_id")) or connection.user_id
        identity = ChannelIdentity(
            channel_id=self.channel_id,
            kind=self.kind,
            account_id=self.account_id,
            peer_id=connection.peer_id,
            thread_id=thread_id,
            peer_type="terminal",
            user_id=user_id,
            message_id=message_id,
        )
        inbound = InboundMessage(
            channel=self.channel_id,
            content=text,
            content_type="text",
            user_id=user_id,
            channel_identity=identity,
            metadata={
                "terminal": {
                    "peer_id": connection.peer_id,
                    "device_name": connection.device_name,
                    "capabilities": connection.capabilities,
                }
            },
        )
        accept = await self.inbound_sink.accept_inbound(inbound)
        ack: dict[str, Any] = {
            "type": "ack",
            "message_id": message_id,
            "session_id": accept.session_id or identity.session_id(),
            "accepted": accept.accepted,
        }
        if accept.duplicate:
            ack["duplicate"] = True
            ack["pending"] = accept.pending
        record = accept.record or {}
        if record.get("reply"):
            ack["reply"] = record["reply"]
        if accept.error or record.get("error"):
            ack["error"] = accept.error or record.get("error")
        await websocket.send_json(ack)

    async def send(self, message: OutboundMessage) -> None:
        session_id = message.session_id
        if not session_id and message.channel_identity is not None:
            session_id = message.channel_identity.session_id()
        connection = self._connections_by_session.get(session_id or "")
        if connection is None:
            message.metadata["delivery_status"] = "unclaimed"
            return

        payload = {
            "type": "message",
            "role": "assistant",
            "message_id": message.channel_identity.message_id if message.channel_identity else message.message_id,
            "run_id": message.run_id,
            "text": message.content,
            "finish_reason": message.finish_reason,
        }
        try:
            await connection.websocket.send_json(payload)
        except Exception:
            message.metadata["delivery_status"] = "unclaimed"
            self._remove_connection(connection)
            return

    def _remove_connection(self, connection: TerminalConnection) -> None:
        current = self._connections_by_session.get(connection.session_id)
        if current is connection:
            self._connections_by_session.pop(connection.session_id, None)
        if self._session_by_peer.get(connection.peer_id) == connection.session_id:
            self._session_by_peer.pop(connection.peer_id, None)

    def _record(
        self,
        *,
        kind: str,
        session_id: str | None = None,
        message_id: str | None = None,
        status: str = "ok",
        error: str | None = None,
        metadata: dict[str, Any] | None = None,
    ) -> None:
        if self.event_recorder is None:
            return
        self.event_recorder(
            channel_id=self.channel_id,
            kind=kind,
            session_id=session_id,
            message_id=message_id,
            status=status,
            error=error,
            metadata=metadata,
        )
  • Step 2: Export the adapter

Modify app-instance/backend/beaver/interfaces/channels/__init__.py so it imports and exports the adapter:

from .terminal_websocket import TerminalWebSocketAdapter

Add "TerminalWebSocketAdapter" to __all__ if this file defines __all__.

  • Step 3: Run import-focused tests

Run:

cd app-instance/backend
uv run pytest tests/unit/test_imports.py -q

Expected: pass.

  • Step 4: Commit adapter skeleton
git add app-instance/backend/beaver/interfaces/channels/terminal_websocket.py app-instance/backend/beaver/interfaces/channels/__init__.py
git commit -m "feat: add terminal websocket channel adapter"

Task 3: Wire Adapter Factory, Status, and WebSocket Route

Files:

  • Modify: app-instance/backend/beaver/interfaces/channels/runtime.py

  • Modify: app-instance/backend/beaver/interfaces/web/app.py

  • Modify: app-instance/nginx.conf

  • Step 1: Add runtime event recorder and terminal adapter factory

Modify app-instance/backend/beaver/interfaces/channels/runtime.py.

Add this method to ChannelRuntime:

    def record_event(
        self,
        *,
        channel_id: str,
        kind: str,
        session_id: str | None = None,
        message_id: str | None = None,
        run_id: str | None = None,
        status: str = "ok",
        error: str | None = None,
        metadata: dict[str, Any] | None = None,
    ) -> None:
        self.events.record(
            channel_id=channel_id,
            kind=kind,
            session_id=session_id,
            message_id=message_id,
            run_id=run_id,
            status=status,
            error=error,
            metadata=metadata,
        )

Update _build_adapter():

    def _build_adapter(self, channel_id: str, cfg: ChannelConfig) -> ChannelAdapter:
        if cfg.kind == "webhook" and cfg.mode == "webhook":
            from beaver.interfaces.channels.generic_webhook import GenericWebhookAdapter

            return GenericWebhookAdapter(
                channel_id=channel_id,
                kind=cfg.kind,
                mode=cfg.mode,
                account_id=cfg.account_id,
                display_name=cfg.display_name,
                inbound_sink=self,
                response_timeout_seconds=float(cfg.config.get("response_timeout_seconds") or 1800),
            )

        if cfg.kind == "terminal" and cfg.mode == "websocket":
            from beaver.interfaces.channels.terminal_websocket import TerminalWebSocketAdapter

            return TerminalWebSocketAdapter(
                channel_id=channel_id,
                kind=cfg.kind,
                mode=cfg.mode,
                account_id=cfg.account_id,
                display_name=cfg.display_name,
                inbound_sink=self,
                event_recorder=self.record_event,
                heartbeat_seconds=float(cfg.config.get("heartbeat_seconds") or 30),
                max_message_chars=int(cfg.config.get("max_message_chars") or 20000),
            )

        raise ValueError(f"Unsupported channel kind/mode: {cfg.kind}/{cfg.mode}")

Update statuses() so terminal channels expose capabilities, URL, and connected peer count:

            capabilities = []
            webhook_url = None
            websocket_url = None
            connected_peers = 0
            if cfg.kind == "webhook":
                capabilities = ["receive_text", "send_text", "sync_webhook_response"]
                webhook_url = f"/api/channels/{channel_id}/webhook"
            elif cfg.kind == "terminal" and cfg.mode == "websocket":
                capabilities = ["receive_text", "send_text", "persistent_connection"]
                websocket_url = f"/api/channels/{channel_id}/ws"
                adapter = self.adapters.get(channel_id)
                if adapter is not None and hasattr(adapter, "status_extra"):
                    extra = adapter.status_extra()  # type: ignore[attr-defined]
                    connected_peers = int(extra.get("connected_peers") or 0)

Include these fields in the status item:

                    "webhook_url": webhook_url,
                    "websocket_url": websocket_url,
                    "connected_peers": connected_peers,
  • Step 2: Add the FastAPI channel websocket route

Modify app-instance/backend/beaver/interfaces/web/app.py near the existing /api/channels/{channel_id}/webhook route:

    @app.websocket("/api/channels/{channel_id}/ws")
    async def channel_websocket(websocket: WebSocket, channel_id: str) -> None:
        runtime = getattr(websocket.app.state, "channel_runtime", None)
        if not isinstance(runtime, ChannelRuntime):
            await websocket.accept()
            await websocket.send_json({"type": "error", "error": "Channel runtime is not running"})
            await websocket.close(code=1011)
            return
        adapter = runtime.adapters.get(channel_id)
        if adapter is None or not hasattr(adapter, "handle_websocket"):
            await websocket.accept()
            await websocket.send_json({"type": "error", "error": "WebSocket channel not found"})
            await websocket.close(code=1008)
            return
        await adapter.handle_websocket(websocket)  # type: ignore[attr-defined]
  • Step 3: Add WebSocket upgrade headers to nginx channel location

Modify app-instance/nginx.conf:

        location /api/channels/ {
            proxy_pass http://127.0.0.1:18080;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
            proxy_read_timeout 3600;
            proxy_send_timeout 3600;
        }
  • Step 4: Run the roundtrip test

Run:

cd app-instance/backend
uv run pytest tests/unit/test_terminal_websocket_channel.py::test_terminal_websocket_connect_ping_and_message_roundtrip -q

Expected: pass.

  • Step 5: Run channel runtime tests

Run:

cd app-instance/backend
uv run pytest tests/unit/test_channel_runtime.py tests/unit/test_gateway_channels.py tests/unit/test_terminal_websocket_channel.py -q

Expected: pass.

  • Step 6: Commit route and runtime wiring
git add app-instance/backend/beaver/interfaces/channels/runtime.py app-instance/backend/beaver/interfaces/web/app.py app-instance/nginx.conf app-instance/backend/tests/unit/test_terminal_websocket_channel.py
git commit -m "feat: wire terminal websocket channel"

Task 4: Add Edge Case Tests and Finish Protocol Semantics

Files:

  • Modify: app-instance/backend/tests/unit/test_terminal_websocket_channel.py

  • Modify: app-instance/backend/beaver/interfaces/channels/terminal_websocket.py

  • Step 1: Add tests for pre-connect, unknown frame, and validation errors

Append to app-instance/backend/tests/unit/test_terminal_websocket_channel.py:

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 == []
  • Step 2: Add duplicate message test

Append:

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
  • Step 3: Add disconnect/unclaimed test

Append:

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

        # Allow the runtime bridge to finish after the socket has closed.
        import time

        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
  • Step 4: Add status exposure test

Append:

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()
  • Step 5: Run edge case tests

Run:

cd app-instance/backend
uv run pytest tests/unit/test_terminal_websocket_channel.py -q

Expected: pass. If any test fails, adjust TerminalWebSocketAdapter only; do not bypass the runtime path.

  • Step 6: Commit edge case coverage
git add app-instance/backend/tests/unit/test_terminal_websocket_channel.py app-instance/backend/beaver/interfaces/channels/terminal_websocket.py
git commit -m "test: cover terminal websocket edge cases"

Task 5: Expose Terminal Status in Frontend Types and Dialog

Files:

  • Modify: app-instance/frontend/types/index.ts

  • Modify: app-instance/frontend/app/(app)/status/page.tsx

  • Step 1: Extend ChannelStatus

Modify app-instance/frontend/types/index.ts:

export interface ChannelStatus {
  channel_id: string;
  name?: string;
  kind: string;
  mode: string;
  display_name: string;
  enabled: boolean;
  state: 'configured' | 'disabled' | 'starting' | 'running' | 'degraded' | 'error' | 'stopped';
  account_id: string;
  last_error?: string | null;
  last_event_at?: string | null;
  started_at?: string | null;
  capabilities: string[];
  webhook_url?: string | null;
  websocket_url?: string | null;
  connected_peers?: number;
}
  • Step 2: Show WebSocket details in the channel dialog

Modify the selected channel InfoRow block in app-instance/frontend/app/(app)/status/page.tsx:

<InfoRow label="State" value={selectedChannel.state} />
<InfoRow label="Account" value={selectedChannel.account_id || '-'} />
<InfoRow label="Webhook" value={selectedChannel.webhook_url || '-'} />
<InfoRow label="WebSocket" value={selectedChannel.websocket_url || '-'} />
<InfoRow label="Connected peers" value={String(selectedChannel.connected_peers ?? 0)} />
<InfoRow label="Last error" value={selectedChannel.last_error || '-'} />
  • Step 3: Show connected peer count in compact channel rows

In the channel row subtitle, replace:

{ch.channel_id} · {ch.kind}/{ch.mode} · {ch.account_id}

with:

{ch.channel_id} · {ch.kind}/{ch.mode} · {ch.account_id}
{typeof ch.connected_peers === 'number' ? ` · ${ch.connected_peers} peer${ch.connected_peers === 1 ? '' : 's'}` : ''}
  • Step 4: Run frontend verification

Run:

cd app-instance/frontend
npm run typecheck
npm test -- --run

Expected: both commands pass.

  • Step 5: Commit frontend status updates
git add app-instance/frontend/types/index.ts 'app-instance/frontend/app/(app)/status/page.tsx'
git commit -m "feat: show terminal websocket channel status"

Task 6: Final Verification and Manual Smoke Notes

Files:

  • No code files unless verification exposes a defect.

  • Step 1: Run backend terminal/channel tests

Run:

cd app-instance/backend
uv run pytest tests/unit/test_terminal_websocket_channel.py tests/unit/test_channel_runtime.py tests/unit/test_gateway_channels.py tests/unit/test_config_loader.py tests/unit/test_imports.py -q

Expected: pass.

  • Step 2: Run full backend unit tests

Run:

cd app-instance/backend
uv run pytest tests/unit -q

Expected: pass.

  • Step 3: Run frontend verification

Run:

cd app-instance/frontend
npm run typecheck
npm test -- --run

Expected: pass.

  • Step 4: Check nginx syntax if nginx is installed

Run:

nginx -t -c /home/ivan/xuan/beaver_project/app-instance/nginx.conf

Expected when nginx is installed: syntax is ok. If the command is missing, record that nginx is not installed and do not claim nginx syntax was verified.

  • Step 5: Check for generated runtime state

Run:

find app-instance/backend/state -maxdepth 3 -type f -print 2>/dev/null || true

Expected: no test-generated state files remain. Remove only generated test state files if they appear under app-instance/backend/state.

  • Step 6: Check diff hygiene

Run:

git diff --check
git status --short

Expected: git diff --check passes. git status --short shows only intentional terminal websocket/channel runtime changes.

  • Step 7: Record manual smoke command for terminal colleagues

Use this command after a local server is running:

websocat ws://127.0.0.1:8080/api/channels/terminal-dev/ws

Paste:

{"type":"connect","peer_id":"device-001","device_name":"desk-terminal","capabilities":["text"]}

Expected:

{"type":"connected","channel_id":"terminal-dev","session_id":"terminal-dev:local:device-001"}

Paste:

{"type":"message","message_id":"device-001-000001","text":"hello"}

Expected:

{"type":"ack","message_id":"device-001-000001","session_id":"terminal-dev:local:device-001","accepted":true}

Then expect a final assistant message:

{"type":"message","role":"assistant","message_id":"device-001-000001","run_id":"...","text":"...","finish_reason":"stop"}
  • Step 8: Final commit if needed

If Tasks 1-5 were not committed separately, make one scoped commit:

git add app-instance/backend/beaver/interfaces/channels/terminal_websocket.py \
  app-instance/backend/beaver/interfaces/channels/__init__.py \
  app-instance/backend/beaver/interfaces/channels/runtime.py \
  app-instance/backend/beaver/interfaces/web/app.py \
  app-instance/backend/tests/unit/test_terminal_websocket_channel.py \
  app-instance/frontend/types/index.ts \
  'app-instance/frontend/app/(app)/status/page.tsx' \
  app-instance/nginx.conf
git commit -m "feat: add terminal websocket channel"

Do not commit unrelated user changes.


Spec Coverage Checklist

  • Text-only WebSocket endpoint: Tasks 2 and 3.
  • Bus-first routing through ChannelRuntime.accept_inbound(): Tasks 1, 2, and 3.
  • Connect/connected/message/ack/assistant frames: Tasks 1 and 2.
  • Ping/pong and error frames: Tasks 1 and 4.
  • Stable session id from peer_id: Tasks 1 and 2.
  • Dedupe behavior: Task 4.
  • Disconnect/unclaimed behavior: Task 4.
  • Status fields and events: Tasks 3, 4, and 5.
  • Nginx WebSocket upgrade: Task 3.
  • Verification and manual smoke command: Task 6.