refactor: split WebUI gateway dependencies
Maintainer edit for PR 4115: rebase onto origin/main and split gateway HTTP routing from token, media, and workspace services so WebSocketChannel depends on explicit gateway services instead of GatewayHTTPHandler internals. Preserve file edit channel capabilities and restore tools.restrict_to_workspace wiring through ChannelManager.
This commit is contained in:
@@ -20,20 +20,30 @@ from nanobot.channels.websocket import (
|
||||
WebSocketChannel,
|
||||
WebSocketConfig,
|
||||
_is_valid_chat_id,
|
||||
_issue_route_secret_matches,
|
||||
_normalize_config_path,
|
||||
_normalize_http_path,
|
||||
_parse_envelope,
|
||||
_parse_inbound_payload,
|
||||
_parse_query,
|
||||
_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
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
||||
from nanobot.webui.http_utils import (
|
||||
issue_route_secret_matches as _issue_route_secret_matches,
|
||||
)
|
||||
from nanobot.webui.http_utils import (
|
||||
normalize_config_path as _normalize_config_path,
|
||||
)
|
||||
from nanobot.webui.http_utils import (
|
||||
normalize_http_path as _normalize_http_path,
|
||||
)
|
||||
from nanobot.webui.http_utils import (
|
||||
parse_query as _parse_query,
|
||||
)
|
||||
from nanobot.webui.http_utils import (
|
||||
parse_request_path as _parse_request_path,
|
||||
)
|
||||
from nanobot.webui.settings_api import settings_payload, update_provider_settings
|
||||
|
||||
# -- Shared helpers (aligned with test_websocket_integration.py) ---------------
|
||||
@@ -52,34 +62,36 @@ def _ch(bus: Any, **kw: Any) -> WebSocketChannel:
|
||||
}
|
||||
cfg.update(kw)
|
||||
parsed = WebSocketConfig.model_validate(cfg)
|
||||
http_handler = GatewayHTTPHandler(
|
||||
gateway = build_gateway_services(
|
||||
config=parsed,
|
||||
bus=bus,
|
||||
session_manager=None,
|
||||
static_dist_path=None,
|
||||
workspace_path=Path.cwd(),
|
||||
default_restrict_to_workspace=False,
|
||||
runtime_model_name=None,
|
||||
runtime_surface="browser",
|
||||
runtime_capabilities_overrides=None,
|
||||
bus=bus,
|
||||
)
|
||||
return WebSocketChannel(cfg, bus, http_handler=http_handler)
|
||||
return WebSocketChannel(cfg, bus, gateway=gateway)
|
||||
|
||||
|
||||
def _basic_handler(bus: Any, **kw: Any) -> GatewayHTTPHandler:
|
||||
def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
|
||||
cfg = WebSocketConfig.model_validate({
|
||||
"enabled": True, "allowFrom": ["*"],
|
||||
"host": "127.0.0.1", "port": _PORT,
|
||||
"path": "/ws", "websocketRequiresToken": False,
|
||||
})
|
||||
return GatewayHTTPHandler(
|
||||
return build_gateway_services(
|
||||
config=cfg,
|
||||
bus=bus,
|
||||
session_manager=kw.get("session_manager"),
|
||||
static_dist_path=None,
|
||||
workspace_path=kw.get("workspace_path", Path.cwd()),
|
||||
default_restrict_to_workspace=kw.get("default_restrict_to_workspace", False),
|
||||
runtime_model_name=None,
|
||||
runtime_surface=kw.get("runtime_surface", "browser"),
|
||||
runtime_capabilities_overrides=kw.get("runtime_capabilities_overrides"),
|
||||
bus=bus,
|
||||
)
|
||||
|
||||
|
||||
@@ -194,7 +206,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),
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
with pytest.raises(ValueError, match="ssl_certfile and ssl_keyfile"):
|
||||
channel._build_ssl_context()
|
||||
@@ -310,7 +322,7 @@ async def test_webui_message_scope_inherits_persisted_session_scope(
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
http_handler=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
|
||||
)
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
@@ -356,7 +368,7 @@ async def test_webui_scope_expands_home_project_path(
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
http_handler=_basic_handler(bus, session_manager=SessionManager(tmp_path / "sessions"), workspace_path=default_workspace),
|
||||
gateway=_basic_handler(bus, session_manager=SessionManager(tmp_path / "sessions"), workspace_path=default_workspace),
|
||||
)
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
@@ -393,7 +405,7 @@ async def test_webui_scope_rejects_missing_project_path(bus: MagicMock, tmp_path
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
http_handler=_basic_handler(bus, session_manager=SessionManager(tmp_path / "sessions"), workspace_path=default_workspace),
|
||||
gateway=_basic_handler(bus, session_manager=SessionManager(tmp_path / "sessions"), workspace_path=default_workspace),
|
||||
)
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
@@ -430,7 +442,7 @@ async def test_webui_scope_rejects_running_scope_change(bus: MagicMock, tmp_path
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
http_handler=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
|
||||
)
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
@@ -486,7 +498,7 @@ async def test_webui_set_workspace_scope_rejects_running_chat(bus: MagicMock, tm
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
http_handler=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
|
||||
)
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
@@ -545,7 +557,7 @@ async def test_webui_scope_rejects_non_loopback_custom_scope(bus: MagicMock, tmp
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
http_handler=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
|
||||
)
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("203.0.113.8", 50123)
|
||||
@@ -574,7 +586,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -600,7 +612,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -647,8 +659,8 @@ async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -
|
||||
return ws_media if channel == "websocket" else media_root
|
||||
|
||||
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, http_handler=_basic_handler(bus))
|
||||
monkeypatch.setattr("nanobot.webui.media_gateway.get_media_dir", fake_media_dir)
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -671,7 +683,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
msg = OutboundMessage(channel="websocket", chat_id="missing", content="x")
|
||||
await channel.send(msg)
|
||||
|
||||
@@ -679,7 +691,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
mock_ws.send.side_effect = ConnectionClosed(Close(1006, ""), Close(1006, ""), True)
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
@@ -694,7 +706,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -742,7 +754,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -791,7 +803,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -815,7 +827,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
mock_ws.send.side_effect = ConnectionClosed(Close(1006, ""), Close(1006, ""), True)
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
@@ -829,7 +841,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -862,11 +874,11 @@ async def test_send_delta_stream_end_rewrites_local_markdown_image(monkeypatch,
|
||||
return path
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
|
||||
monkeypatch.setattr("nanobot.webui.ws_http.get_media_dir", fake_media_dir)
|
||||
monkeypatch.setattr("nanobot.webui.media_gateway.get_media_dir", fake_media_dir)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
||||
bus,
|
||||
http_handler=_basic_handler(bus, workspace_path=workspace),
|
||||
gateway=_basic_handler(bus, workspace_path=workspace),
|
||||
)
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
@@ -895,11 +907,11 @@ async def test_send_delta_stream_end_rewrites_inline_final_text(monkeypatch, tmp
|
||||
return path
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
|
||||
monkeypatch.setattr("nanobot.webui.ws_http.get_media_dir", fake_media_dir)
|
||||
monkeypatch.setattr("nanobot.webui.media_gateway.get_media_dir", fake_media_dir)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
||||
bus,
|
||||
http_handler=_basic_handler(bus, workspace_path=workspace),
|
||||
gateway=_basic_handler(bus, workspace_path=workspace),
|
||||
)
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
@@ -919,7 +931,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -940,7 +952,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -956,7 +968,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -978,7 +990,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -990,7 +1002,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
|
||||
await channel.send_reasoning_delta("unattached", "thinking", None)
|
||||
await channel.send_reasoning_end("unattached", None)
|
||||
@@ -1000,7 +1012,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -1019,7 +1031,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -1038,7 +1050,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -1058,7 +1070,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -1086,7 +1098,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -1109,7 +1121,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_a = AsyncMock()
|
||||
mock_b = AsyncMock()
|
||||
channel._attach(mock_a, "chat-a")
|
||||
@@ -1138,10 +1150,9 @@ 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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
channel._session_manager = None
|
||||
await channel._maybe_push_active_goal_state("chat-1")
|
||||
mock_ws.send.assert_not_called()
|
||||
|
||||
@@ -1149,10 +1160,13 @@ 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, http_handler=_basic_handler(bus))
|
||||
sm = MagicMock()
|
||||
sm.read_session_file.return_value = None
|
||||
channel._session_manager = sm
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sm),
|
||||
)
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
await channel._maybe_push_active_goal_state("chat-1")
|
||||
@@ -1162,7 +1176,6 @@ 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, http_handler=_basic_handler(bus))
|
||||
sm = MagicMock()
|
||||
sm.read_session_file.return_value = {
|
||||
"metadata": {
|
||||
@@ -1174,7 +1187,11 @@ async def test_maybe_push_active_goal_state_notifies_when_goal_active_on_disk()
|
||||
},
|
||||
"messages": [],
|
||||
}
|
||||
channel._session_manager = sm
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sm),
|
||||
)
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
await channel._maybe_push_active_goal_state("chat-1")
|
||||
@@ -1190,7 +1207,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
from nanobot.session import webui_turns as wth
|
||||
@@ -1203,7 +1220,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
from nanobot.session import webui_turns as wth
|
||||
@@ -1228,7 +1245,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -1247,7 +1264,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
@@ -1266,7 +1283,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
mock_ws = AsyncMock()
|
||||
mock_ws.send.side_effect = RuntimeError("unexpected")
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
@@ -1279,7 +1296,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, gateway=_basic_handler(bus))
|
||||
# No exception, no error — just a no-op
|
||||
await channel.send_delta("nonexistent", "chunk", {"_stream_delta": True, "_stream_id": "s1"})
|
||||
|
||||
@@ -1287,7 +1304,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, http_handler=_basic_handler(bus))
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
# stop() before start() should not raise
|
||||
await channel.stop()
|
||||
await channel.stop()
|
||||
@@ -1448,7 +1465,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
)
|
||||
|
||||
channel = _ch(bus, port=port)
|
||||
channel._api_tokens["tok"] = time.monotonic() + 300
|
||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
@@ -1733,7 +1750,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
async def test_commands_api_returns_slash_command_metadata(bus: MagicMock) -> None:
|
||||
port = 29892
|
||||
channel = _ch(bus, port=port)
|
||||
channel._api_tokens["tok"] = time.monotonic() + 300
|
||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
@@ -1771,7 +1788,7 @@ async def test_bootstrap_exposes_native_surface(bus: MagicMock) -> None:
|
||||
"websocketRequiresToken": True,
|
||||
},
|
||||
bus,
|
||||
http_handler=_basic_handler(bus, runtime_surface="native", runtime_capabilities_overrides={"can_pick_folder": True}),
|
||||
gateway=_basic_handler(bus, runtime_surface="native", runtime_capabilities_overrides={"can_pick_folder": True}),
|
||||
)
|
||||
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
@@ -1941,8 +1958,9 @@ async def test_token_issue_rejects_when_at_capacity(bus: MagicMock) -> None:
|
||||
|
||||
try:
|
||||
# Fill issued tokens to capacity
|
||||
channel._http.issued_tokens = {
|
||||
f"nbwt_fill_{i}": time.monotonic() + 300 for i in range(channel._http._MAX_ISSUED_TOKENS)
|
||||
channel.gateway.tokens.issued_tokens = {
|
||||
f"nbwt_fill_{i}": time.monotonic() + 300
|
||||
for i in range(channel.gateway.tokens.max_tokens)
|
||||
}
|
||||
|
||||
resp = await _http_get(
|
||||
@@ -2299,10 +2317,8 @@ def test_sessions_list_includes_active_run_started_at() -> None:
|
||||
from nanobot.session import webui_turns as wth
|
||||
|
||||
bus = MagicMock()
|
||||
channel = _ch(bus)
|
||||
channel._api_tokens["tok"] = time.monotonic() + 300.0
|
||||
channel._session_manager = MagicMock()
|
||||
channel._session_manager.list_sessions.return_value = [
|
||||
session_manager = MagicMock()
|
||||
session_manager.list_sessions.return_value = [
|
||||
{
|
||||
"key": "websocket:chat-1",
|
||||
"created_at": "2026-05-19T10:00:00Z",
|
||||
@@ -2317,19 +2333,25 @@ def test_sessions_list_includes_active_run_started_at() -> None:
|
||||
"updated_at": "2026-05-19T10:01:00Z",
|
||||
},
|
||||
]
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=session_manager),
|
||||
)
|
||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
|
||||
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||
try:
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-1"] = 1_700_000_000.0
|
||||
req = Request("/api/sessions", Headers([("Authorization", "Bearer tok")]))
|
||||
resp = channel._handle_sessions_list(req)
|
||||
resp = channel.gateway.http._handle_sessions_list(req)
|
||||
finally:
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(resp.body.decode())
|
||||
workspace_scope = body["sessions"][0].pop("workspace_scope")
|
||||
assert workspace_scope["project_path"] == str(channel._workspace_path)
|
||||
assert workspace_scope["project_path"] == str(channel.gateway.media.workspace_path)
|
||||
assert workspace_scope["access_mode"] in {"restricted", "full"}
|
||||
assert body["sessions"] == [
|
||||
{
|
||||
@@ -2376,10 +2398,10 @@ def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None:
|
||||
append_transcript_object(key, {"event": "user", "chat_id": "c1", "text": "hi"})
|
||||
bus = MagicMock()
|
||||
channel = _ch(bus)
|
||||
channel._api_tokens["tok"] = time.monotonic() + 300.0
|
||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
|
||||
enc = quote(key, safe="")
|
||||
req = Request(f"/api/sessions/{enc}/webui-thread", Headers([("Authorization", "Bearer tok")]))
|
||||
resp = channel._handle_webui_thread_get(req, enc)
|
||||
resp = channel.gateway.http._handle_webui_thread_get(req, enc)
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(resp.body.decode())
|
||||
assert body["sessionKey"] == key
|
||||
|
||||
Reference in New Issue
Block a user