mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 22:34:06 +00:00
refactor: WebSocketChannel accepts injected http_handler, update all tests
This commit is contained in:
parent
22673c2a27
commit
e5eb08e3e5
@ -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)
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user