refactor: WebSocketChannel accepts injected http_handler, update all tests

This commit is contained in:
chengyongru
2026-06-02 17:14:38 +08:00
committed by Xubin Ren
parent 22673c2a27
commit e5eb08e3e5
6 changed files with 160 additions and 104 deletions
+75 -49
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(