refactor: WebSocketChannel accepts injected http_handler, update all tests

This commit is contained in:
chengyongru 2026-05-31 19:05:23 +08:00 committed by Xubin Ren
parent 22673c2a27
commit e5eb08e3e5
6 changed files with 160 additions and 104 deletions

View File

@ -28,7 +28,6 @@ from websockets.http11 import Response
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.webui.ws_http import GatewayHTTPHandler
from nanobot.config.paths import get_media_dir, get_workspace_path
from nanobot.config.schema import Base
from nanobot.security.workspace_access import (
@ -457,12 +456,10 @@ class WebSocketChannel(BaseChannel):
bus: MessageBus,
*,
session_manager: "SessionManager | None" = None,
static_dist_path: Path | None = None,
http_handler: Any | None = None,
workspace_path: Path | None = None,
restrict_to_workspace: bool = False,
runtime_model_name: Callable[[], str | None] | None = None,
runtime_surface: str = "browser",
runtime_capabilities_overrides: dict[str, Any] | None = None,
):
if isinstance(config, dict):
config = WebSocketConfig.model_validate(config)
@ -476,32 +473,16 @@ class WebSocketChannel(BaseChannel):
self._conn_default: dict[Any, str] = {}
self._stop_event: asyncio.Event | None = None
self._server_task: asyncio.Task[None] | None = None
_resolved_workspace = (
Path(workspace_path).expanduser()
if workspace_path is not None
else get_workspace_path()
).resolve(strict=False)
self._default_restrict_to_workspace = restrict_to_workspace
self._runtime_surface = (
"native" if runtime_surface in {"native", "desktop"} else "browser"
)
# HTTP API handler — owns tokens, sessions, media, settings, static serving
self._http = GatewayHTTPHandler(
config=self.config,
session_manager=session_manager,
static_dist_path=(
static_dist_path.resolve() if static_dist_path is not None else None
),
workspace_path=_resolved_workspace,
runtime_model_name=runtime_model_name,
runtime_surface=self._runtime_surface,
runtime_capabilities_overrides=runtime_capabilities_overrides,
bus=self.bus,
log=self.logger,
)
# Backwards-compat aliases for workspace controller used in envelope dispatch
self._webui_workspaces = self._http.workspaces
# HTTP handler injected from outside (ChannelManager / gateway startup).
# Owns tokens, sessions, media, settings, static serving.
self._http = http_handler
# Backwards-compat: workspace controller used in envelope dispatch
self._webui_workspaces = http_handler.workspaces if http_handler else None
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
@ -614,8 +595,6 @@ class WebSocketChannel(BaseChannel):
def _handle_bootstrap(self, connection, request):
return self._http._handle_bootstrap(connection, request)
_MAX_ISSUED_TOKENS = GatewayHTTPHandler._MAX_ISSUED_TOKENS
def _handle_sessions_list(self, request):
return self._http._handle_sessions_list(request)

View File

@ -4,6 +4,7 @@ import asyncio
import functools
import json
import time
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
@ -28,6 +29,7 @@ from nanobot.channels.websocket import (
_parse_request_path,
publish_runtime_model_update,
)
from nanobot.webui.ws_http import GatewayHTTPHandler
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.session import webui_turns as wth
@ -49,7 +51,36 @@ def _ch(bus: Any, **kw: Any) -> WebSocketChannel:
"websocketRequiresToken": False,
}
cfg.update(kw)
return WebSocketChannel(cfg, bus)
parsed = WebSocketConfig.model_validate(cfg)
http_handler = GatewayHTTPHandler(
config=parsed,
session_manager=None,
static_dist_path=None,
workspace_path=Path.cwd(),
runtime_model_name=None,
runtime_surface="browser",
runtime_capabilities_overrides=None,
bus=bus,
)
return WebSocketChannel(cfg, bus, http_handler=http_handler)
def _basic_handler(bus: Any, **kw: Any) -> GatewayHTTPHandler:
cfg = WebSocketConfig.model_validate({
"enabled": True, "allowFrom": ["*"],
"host": "127.0.0.1", "port": _PORT,
"path": "/ws", "websocketRequiresToken": False,
})
return GatewayHTTPHandler(
config=cfg,
session_manager=kw.get("session_manager"),
static_dist_path=None,
workspace_path=kw.get("workspace_path", Path.cwd()),
runtime_model_name=None,
runtime_surface=kw.get("runtime_surface", "browser"),
runtime_capabilities_overrides=kw.get("runtime_capabilities_overrides"),
bus=bus,
)
@pytest.fixture()
@ -163,6 +194,7 @@ def test_ssl_context_requires_both_cert_and_key_files() -> None:
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "sslCertfile": "/tmp/c.pem", "sslKeyfile": ""},
bus,
http_handler=_basic_handler(bus),
)
with pytest.raises(ValueError, match="ssl_certfile and ssl_keyfile"):
channel._build_ssl_context()
@ -279,8 +311,7 @@ async def test_webui_message_scope_inherits_persisted_session_scope(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
session_manager=sessions,
workspace_path=default_workspace,
restrict_to_workspace=True,
http_handler=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
@ -327,8 +358,7 @@ async def test_webui_scope_expands_home_project_path(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
session_manager=SessionManager(tmp_path / "sessions"),
workspace_path=default_workspace,
restrict_to_workspace=True,
http_handler=_basic_handler(bus, session_manager=SessionManager(tmp_path / "sessions"), workspace_path=default_workspace),
)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
@ -366,7 +396,7 @@ async def test_webui_scope_rejects_missing_project_path(bus: MagicMock, tmp_path
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
session_manager=SessionManager(tmp_path / "sessions"),
workspace_path=default_workspace,
http_handler=_basic_handler(bus, session_manager=SessionManager(tmp_path / "sessions"), workspace_path=default_workspace),
)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
@ -404,8 +434,7 @@ async def test_webui_scope_rejects_running_scope_change(bus: MagicMock, tmp_path
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
session_manager=sessions,
workspace_path=default_workspace,
restrict_to_workspace=True,
http_handler=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
@ -462,8 +491,7 @@ async def test_webui_set_workspace_scope_rejects_running_chat(bus: MagicMock, tm
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
session_manager=sessions,
workspace_path=default_workspace,
restrict_to_workspace=True,
http_handler=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
@ -523,8 +551,7 @@ async def test_webui_scope_rejects_non_loopback_custom_scope(bus: MagicMock, tmp
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
session_manager=sessions,
workspace_path=default_workspace,
restrict_to_workspace=True,
http_handler=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
)
conn = AsyncMock()
conn.remote_address = ("203.0.113.8", 50123)
@ -553,7 +580,7 @@ async def test_webui_scope_rejects_non_loopback_custom_scope(bus: MagicMock, tmp
@pytest.mark.asyncio
async def test_send_delivers_json_message_with_media_and_reply() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -579,7 +606,7 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None:
@pytest.mark.asyncio
async def test_send_broadcasts_runtime_model_updates() -> None:
bus = MessageBus()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -627,7 +654,7 @@ async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
monkeypatch.setattr("nanobot.webui.ws_http.get_media_dir", fake_media_dir)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -650,7 +677,7 @@ async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -
@pytest.mark.asyncio
async def test_send_missing_connection_is_noop_without_error() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
msg = OutboundMessage(channel="websocket", chat_id="missing", content="x")
await channel.send(msg)
@ -658,7 +685,7 @@ async def test_send_missing_connection_is_noop_without_error() -> None:
@pytest.mark.asyncio
async def test_send_removes_connection_on_connection_closed() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
mock_ws.send.side_effect = ConnectionClosed(Close(1006, ""), Close(1006, ""), True)
channel._attach(mock_ws, "chat-1")
@ -673,7 +700,7 @@ async def test_send_removes_connection_on_connection_closed() -> None:
@pytest.mark.asyncio
async def test_send_progress_includes_structured_tool_events() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -721,7 +748,7 @@ async def test_send_progress_includes_structured_tool_events() -> None:
@pytest.mark.asyncio
async def test_send_file_edit_progress_uses_file_edit_event() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -770,7 +797,7 @@ async def test_send_file_edit_progress_uses_file_edit_event() -> None:
@pytest.mark.asyncio
async def test_send_progress_includes_agent_ui_blob() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -794,7 +821,7 @@ async def test_send_progress_includes_agent_ui_blob() -> None:
@pytest.mark.asyncio
async def test_send_delta_removes_connection_on_connection_closed() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
mock_ws.send.side_effect = ConnectionClosed(Close(1006, ""), Close(1006, ""), True)
channel._attach(mock_ws, "chat-1")
@ -808,7 +835,7 @@ async def test_send_delta_removes_connection_on_connection_closed() -> None:
@pytest.mark.asyncio
async def test_send_delta_emits_delta_and_stream_end() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -845,7 +872,7 @@ async def test_send_delta_stream_end_rewrites_local_markdown_image(monkeypatch,
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
bus,
workspace_path=workspace,
http_handler=_basic_handler(bus, workspace_path=workspace),
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -878,7 +905,7 @@ async def test_send_delta_stream_end_rewrites_inline_final_text(monkeypatch, tmp
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
bus,
workspace_path=workspace,
http_handler=_basic_handler(bus, workspace_path=workspace),
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -898,7 +925,7 @@ async def test_send_delta_stream_end_rewrites_inline_final_text(monkeypatch, tmp
@pytest.mark.asyncio
async def test_send_reasoning_delta_emits_streaming_frame() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -919,7 +946,7 @@ async def test_send_reasoning_delta_emits_streaming_frame() -> None:
@pytest.mark.asyncio
async def test_send_reasoning_end_emits_close_frame() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -935,7 +962,7 @@ async def test_send_reasoning_one_shot_expands_to_delta_plus_end() -> None:
the base implementation must produce one delta and one end so the
WebUI sees the same shape either way."""
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -957,7 +984,7 @@ async def test_send_reasoning_one_shot_expands_to_delta_plus_end() -> None:
@pytest.mark.asyncio
async def test_send_reasoning_delta_drops_empty_chunks() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -969,7 +996,7 @@ async def test_send_reasoning_delta_drops_empty_chunks() -> None:
@pytest.mark.asyncio
async def test_send_reasoning_without_subscribers_is_noop() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
await channel.send_reasoning_delta("unattached", "thinking", None)
await channel.send_reasoning_end("unattached", None)
@ -979,7 +1006,7 @@ async def test_send_reasoning_without_subscribers_is_noop() -> None:
@pytest.mark.asyncio
async def test_send_turn_end_emits_turn_end_event() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -998,7 +1025,7 @@ async def test_send_turn_end_emits_turn_end_event() -> None:
@pytest.mark.asyncio
async def test_send_turn_end_includes_latency_ms_when_present() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -1017,7 +1044,7 @@ async def test_send_turn_end_includes_latency_ms_when_present() -> None:
@pytest.mark.asyncio
async def test_send_turn_end_includes_goal_state_when_present() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -1037,7 +1064,7 @@ async def test_send_turn_end_includes_goal_state_when_present() -> None:
@pytest.mark.asyncio
async def test_send_goal_status_running_emits_event_with_started_at() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -1065,7 +1092,7 @@ async def test_send_goal_status_running_emits_event_with_started_at() -> None:
@pytest.mark.asyncio
async def test_send_goal_status_idle_omits_started_at() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -1088,7 +1115,7 @@ async def test_send_goal_status_idle_omits_started_at() -> None:
@pytest.mark.asyncio
async def test_send_goal_state_emits_blob_per_chat() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_a = AsyncMock()
mock_b = AsyncMock()
channel._attach(mock_a, "chat-a")
@ -1117,7 +1144,7 @@ async def test_send_goal_state_emits_blob_per_chat() -> None:
@pytest.mark.asyncio
async def test_maybe_push_active_goal_state_noop_without_session_manager() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
channel._session_manager = None
@ -1128,7 +1155,7 @@ async def test_maybe_push_active_goal_state_noop_without_session_manager() -> No
@pytest.mark.asyncio
async def test_maybe_push_active_goal_state_skips_when_no_goal_on_disk() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
sm = MagicMock()
sm.read_session_file.return_value = None
channel._session_manager = sm
@ -1141,7 +1168,7 @@ async def test_maybe_push_active_goal_state_skips_when_no_goal_on_disk() -> None
@pytest.mark.asyncio
async def test_maybe_push_active_goal_state_notifies_when_goal_active_on_disk() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
sm = MagicMock()
sm.read_session_file.return_value = {
"metadata": {
@ -1169,7 +1196,7 @@ async def test_maybe_push_active_goal_state_notifies_when_goal_active_on_disk()
@pytest.mark.asyncio
async def test_maybe_push_turn_run_wall_clock_skips_when_no_active_turn() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
from nanobot.session import webui_turns as wth
@ -1182,7 +1209,7 @@ async def test_maybe_push_turn_run_wall_clock_skips_when_no_active_turn() -> Non
@pytest.mark.asyncio
async def test_maybe_push_turn_run_wall_clock_replays_running() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
from nanobot.session import webui_turns as wth
@ -1207,7 +1234,7 @@ async def test_maybe_push_turn_run_wall_clock_replays_running() -> None:
@pytest.mark.asyncio
async def test_send_session_updated_emits_session_updated_event() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -1226,7 +1253,7 @@ async def test_send_session_updated_emits_session_updated_event() -> None:
@pytest.mark.asyncio
async def test_send_session_updated_includes_scope_when_present() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@ -1245,7 +1272,7 @@ async def test_send_session_updated_includes_scope_when_present() -> None:
@pytest.mark.asyncio
async def test_send_non_connection_closed_exception_is_raised() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
mock_ws = AsyncMock()
mock_ws.send.side_effect = RuntimeError("unexpected")
channel._attach(mock_ws, "chat-1")
@ -1258,7 +1285,7 @@ async def test_send_non_connection_closed_exception_is_raised() -> None:
@pytest.mark.asyncio
async def test_send_delta_missing_connection_is_noop() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, http_handler=_basic_handler(bus))
# No exception, no error — just a no-op
await channel.send_delta("nonexistent", "chunk", {"_stream_delta": True, "_stream_id": "s1"})
@ -1266,7 +1293,7 @@ async def test_send_delta_missing_connection_is_noop() -> None:
@pytest.mark.asyncio
async def test_stop_is_idempotent() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
# stop() before start() should not raise
await channel.stop()
await channel.stop()
@ -1750,8 +1777,7 @@ async def test_bootstrap_exposes_native_surface(bus: MagicMock) -> None:
"websocketRequiresToken": True,
},
bus,
runtime_surface="native",
runtime_capabilities_overrides={"can_pick_folder": True},
http_handler=_basic_handler(bus, runtime_surface="native", runtime_capabilities_overrides={"can_pick_folder": True}),
)
server_task = asyncio.create_task(channel.start())
@ -1921,8 +1947,8 @@ async def test_token_issue_rejects_when_at_capacity(bus: MagicMock) -> None:
try:
# Fill issued tokens to capacity
channel._issued_tokens = {
f"nbwt_fill_{i}": time.monotonic() + 300 for i in range(channel._MAX_ISSUED_TOKENS)
channel._http.issued_tokens = {
f"nbwt_fill_{i}": time.monotonic() + 300 for i in range(channel._http._MAX_ISSUED_TOKENS)
}
resp = await _http_get(

View File

@ -18,8 +18,10 @@ import pytest
from nanobot.channels.websocket import (
WebSocketChannel,
WebSocketConfig,
_extract_data_url_mime,
)
from nanobot.webui.ws_http import GatewayHTTPHandler
def _tiny_png_data_url() -> str:
@ -41,10 +43,19 @@ def _data_url(mime: str, payload: bytes) -> str:
def _make_channel() -> WebSocketChannel:
bus = MagicMock()
bus.publish_inbound = AsyncMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "websocketRequiresToken": False},
bus,
cfg = {"enabled": True, "allowFrom": ["*"], "websocketRequiresToken": False}
parsed = WebSocketConfig.model_validate(cfg)
handler = GatewayHTTPHandler(
config=parsed,
session_manager=None,
static_dist_path=None,
workspace_path=Path.cwd(),
runtime_model_name=None,
runtime_surface="browser",
runtime_capabilities_overrides=None,
bus=bus,
)
channel = WebSocketChannel(cfg, bus, http_handler=handler)
channel._handle_message = AsyncMock() # type: ignore[method-assign]
return channel

View File

@ -11,12 +11,35 @@ from urllib.parse import urlencode
import httpx
import pytest
from nanobot.channels.websocket import WebSocketChannel
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.session.manager import Session, SessionManager
from nanobot.webui.ws_http import GatewayHTTPHandler
_PORT = 29900
def _make_handler(
cfg: dict[str, Any] | WebSocketConfig,
bus: Any,
*,
session_manager: SessionManager | None = None,
static_dist_path: Path | None = None,
runtime_model_name: Any | None = None,
) -> GatewayHTTPHandler:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
workspace = Path.cwd()
return GatewayHTTPHandler(
config=config,
session_manager=session_manager,
static_dist_path=static_dist_path,
workspace_path=workspace,
runtime_model_name=runtime_model_name,
runtime_surface="browser",
runtime_capabilities_overrides=None,
bus=bus,
)
def _ch(
bus: Any,
*,
@ -35,17 +58,13 @@ def _ch(
"websocketRequiresToken": False,
}
cfg.update(extra)
ws_kwargs: dict[str, Any] = {
"session_manager": session_manager,
"static_dist_path": static_dist_path,
}
if runtime_model_name is not None:
ws_kwargs["runtime_model_name"] = runtime_model_name
return WebSocketChannel(
cfg,
bus,
**ws_kwargs,
http_handler = _make_handler(
cfg, bus,
session_manager=session_manager,
static_dist_path=static_dist_path,
runtime_model_name=runtime_model_name,
)
return WebSocketChannel(cfg, bus, http_handler=http_handler)
@pytest.fixture()

View File

@ -8,14 +8,16 @@ from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
import websockets
from nanobot.channels.websocket import WebSocketChannel
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.bus.events import OutboundMessage
from nanobot.webui.ws_http import GatewayHTTPHandler
from ws_test_client import WsTestClient, issue_token, issue_token_ok
@ -29,7 +31,18 @@ def _ch(bus: Any, port: int, **kw: Any) -> WebSocketChannel:
"websocketRequiresToken": False,
}
cfg.update(kw)
return WebSocketChannel(cfg, bus)
parsed = WebSocketConfig.model_validate(cfg)
handler = GatewayHTTPHandler(
config=parsed,
session_manager=None,
static_dist_path=None,
workspace_path=Path.cwd(),
runtime_model_name=None,
runtime_surface="browser",
runtime_capabilities_overrides=None,
bus=bus,
)
return WebSocketChannel(cfg, bus, http_handler=handler)
@pytest.fixture()

View File

@ -21,12 +21,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from nanobot.channels.websocket import WebSocketChannel
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.webui.media_api import (
b64url_decode,
b64url_encode,
)
from nanobot.session.manager import Session, SessionManager
from nanobot.webui.ws_http import GatewayHTTPHandler
# PNG magic bytes + a couple of sentinel bytes so we can verify byte-for-byte
@ -47,19 +48,26 @@ def _ch(
workspace_path: Path | None = None,
port: int,
) -> WebSocketChannel:
return WebSocketChannel(
{
"enabled": True,
"allowFrom": ["*"],
"host": "127.0.0.1",
"port": port,
"path": "/",
"websocketRequiresToken": False,
},
bus,
cfg = {
"enabled": True,
"allowFrom": ["*"],
"host": "127.0.0.1",
"port": port,
"path": "/",
"websocketRequiresToken": False,
}
parsed = WebSocketConfig.model_validate(cfg)
http_handler = GatewayHTTPHandler(
config=parsed,
session_manager=session_manager,
workspace_path=workspace_path,
static_dist_path=None,
workspace_path=workspace_path or Path.cwd(),
runtime_model_name=None,
runtime_surface="browser",
runtime_capabilities_overrides=None,
bus=bus,
)
return WebSocketChannel(cfg, bus, http_handler=http_handler)
@pytest.fixture()