diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 7ba9162bb..062765d24 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -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) diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index e0743a8d1..e3482ab56 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -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( diff --git a/tests/channels/test_websocket_envelope_media.py b/tests/channels/test_websocket_envelope_media.py index 982525c32..4d840f263 100644 --- a/tests/channels/test_websocket_envelope_media.py +++ b/tests/channels/test_websocket_envelope_media.py @@ -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 diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index fb529599f..f58f72781 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -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() diff --git a/tests/channels/test_websocket_integration.py b/tests/channels/test_websocket_integration.py index 8e98aa480..cb0bf7606 100644 --- a/tests/channels/test_websocket_integration.py +++ b/tests/channels/test_websocket_integration.py @@ -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() diff --git a/tests/channels/test_websocket_media_route.py b/tests/channels/test_websocket_media_route.py index b8926e273..71bca63b7 100644 --- a/tests/channels/test_websocket_media_route.py +++ b/tests/channels/test_websocket_media_route.py @@ -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()