diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index a15c4ab1a..6534799cc 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -380,6 +380,10 @@ class WebSocketChannel(BaseChannel): tuple[ServerConnection, str], asyncio.Task[None], ] = {} + # Preserve request/response order for non-replayable mutations from one + # UI. Without this, an earlier slow settings response can overwrite a + # newer settings snapshot in the client. + self._webui_request_locks: dict[ServerConnection, asyncio.Lock] = {} self._stop_event: asyncio.Event | None = None self._server_task: asyncio.Task[None] | None = None @@ -476,6 +480,7 @@ class WebSocketChannel(BaseChannel): await self._discard_connection_owned_chat(connection, cid) self._conn_default.pop(connection, None) self._webui_connections.discard(connection) + self._webui_request_locks.pop(connection, None) async def _maybe_push_active_goal_state(self, chat_id: str) -> None: """Replay an active sustained goal from session metadata after *chat_id* is subscribed. @@ -900,7 +905,10 @@ class WebSocketChannel(BaseChannel): ) return if t == "transcribe_audio": - event, payload = await webui_transcription_event(envelope) + event, payload = await webui_transcription_event( + envelope, + config_path=self.gateway.settings.config.path, + ) await self._send_event(connection, event, **payload) return if t == "message": @@ -1039,7 +1047,10 @@ class WebSocketChannel(BaseChannel): cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps")) if cli_apps: metadata["cli_apps"] = cli_apps - mcp_presets = normalize_mcp_preset_mentions(envelope.get("mcp_presets")) + mcp_presets = normalize_mcp_preset_mentions( + envelope.get("mcp_presets"), + config_path=self.gateway.settings.config.path, + ) if mcp_presets: metadata["mcp_presets"] = mcp_presets session_mentions: list[SessionMention] = [] @@ -1198,41 +1209,43 @@ class WebSocketChannel(BaseChannel): payload: dict[str, Any], ) -> None: try: - response = await self._http_router.dispatch_webui_mutation( - connection, - action, - payload, - ) - status = response.status_code - body = bytes(response.body).decode("utf-8", errors="replace").strip() - if 200 <= status < 300: - try: - result = json.loads(body) - except json.JSONDecodeError: + lock = self._webui_request_locks.setdefault(connection, asyncio.Lock()) + async with lock: + response = await self._http_router.dispatch_webui_mutation( + connection, + action, + payload, + ) + status = response.status_code + body = bytes(response.body).decode("utf-8", errors="replace").strip() + if 200 <= status < 300: + try: + result = json.loads(body) + except json.JSONDecodeError: + await self._send_webui_response( + connection, + request_id, + status=502, + message="WebUI mutation returned an invalid response", + ) + return + if action == "sidebar.update" and isinstance(result, dict): + await self._broadcast_webui_event( + "sidebar_state_updated", + state=result, + ) await self._send_webui_response( connection, request_id, - status=502, - message="WebUI mutation returned an invalid response", + result=result, ) return - if action == "sidebar.update" and isinstance(result, dict): - await self._broadcast_webui_event( - "sidebar_state_updated", - state=result, - ) await self._send_webui_response( connection, request_id, - result=result, + status=status, + message=body or response.reason_phrase, ) - return - await self._send_webui_response( - connection, - request_id, - status=status, - message=body or response.reason_phrase, - ) except asyncio.CancelledError: raise except Exception: @@ -1321,6 +1334,7 @@ class WebSocketChannel(BaseChannel): if mutation_tasks: await asyncio.gather(*mutation_tasks, return_exceptions=True) self._webui_request_tasks.clear() + self._webui_request_locks.clear() self._subs.clear() self._conn_chats.clear() self._conn_default.clear() diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index c9833d0d1..c4bf7efb3 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -943,6 +943,65 @@ async def test_authenticated_webui_request_returns_correlated_success(bus: Magic } +@pytest.mark.asyncio +async def test_webui_mutations_preserve_request_and_response_order(bus: MagicMock) -> None: + channel = _ch(bus) + conn = AsyncMock() + channel._webui_connections.add(conn) + first_started = asyncio.Event() + release_first = asyncio.Event() + dispatch_order: list[str] = [] + + async def dispatch( + _connection: object, + action: str, + _payload: dict[str, object], + ) -> Any: + dispatch_order.append(action) + if action == "settings.provider.update": + first_started.set() + await release_first.wait() + return _http_json_response({"action": action}) + + channel.gateway.http.dispatch_webui_mutation = dispatch + await channel._dispatch_envelope( + conn, + "webui-client", + { + "type": "webui_request", + "request_id": "request-first", + "action": "settings.provider.update", + "payload": {}, + }, + ) + await first_started.wait() + await channel._dispatch_envelope( + conn, + "webui-client", + { + "type": "webui_request", + "request_id": "request-second", + "action": "settings.agent.update", + "payload": {}, + }, + ) + await asyncio.sleep(0) + assert dispatch_order == ["settings.provider.update"] + + release_first.set() + await asyncio.gather(*tuple(channel._webui_request_tasks.values())) + + assert dispatch_order == [ + "settings.provider.update", + "settings.agent.update", + ] + responses = [json.loads(call.args[0]) for call in conn.send.await_args_list] + assert [response["request_id"] for response in responses] == [ + "request-first", + "request-second", + ] + + @pytest.mark.asyncio async def test_webui_request_returns_correlated_route_error(bus: MagicMock) -> None: channel = _ch(bus) diff --git a/nanobot/channels/websocket/tests/test_websocket_http_routes.py b/nanobot/channels/websocket/tests/test_websocket_http_routes.py index e583047ae..44093213e 100644 --- a/nanobot/channels/websocket/tests/test_websocket_http_routes.py +++ b/nanobot/channels/websocket/tests/test_websocket_http_routes.py @@ -16,6 +16,7 @@ import pytest from nanobot.bus.events import OutboundMessage from nanobot.channels.base import BaseChannel from nanobot.channels.websocket.runtime import WebSocketChannel, WebSocketConfig +from nanobot.config.loader import load_config, save_config from nanobot.cron.service import CronService from nanobot.cron.types import CronJob, CronPayload, CronSchedule from nanobot.optional_features import InstallResult @@ -636,6 +637,7 @@ async def test_webui_skill_management_routes( *, enabled: bool, disabled_skills: set[str], + config_path: Path | None = None, ) -> dict[str, Any]: assert workspace == tmp_path assert name == "custom-skill" @@ -648,6 +650,7 @@ async def test_webui_skill_management_routes( name: str, *, disabled_skills: set[str], + config_path: Path | None = None, ) -> dict[str, Any]: assert workspace == tmp_path assert name == "custom-skill" @@ -926,10 +929,6 @@ async def test_webui_skill_install_honors_remote_install_opt_in( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - policy = MagicMock() - policy.tools.webui_allow_remote_package_install = True - monkeypatch.setattr("nanobot.config.loader.load_config", lambda: policy) - async def install( source: str, skill_id: str, @@ -956,6 +955,9 @@ async def test_webui_skill_install_honors_remote_install_opt_in( workspace_path=tmp_path, port=_free_port(), ) + policy = load_config(channel.gateway.settings.config.path) + policy.tools.webui_allow_remote_package_install = True + save_config(policy, channel.gateway.settings.config.path) response = await _webui_mutate( channel, "skill.install", @@ -3699,7 +3701,7 @@ def test_authenticated_bootstrap_returns_distinct_api_token(bus: MagicMock) -> N def test_bootstrap_prefers_runtime_model_name(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( "nanobot.webui.ws_http._default_model_name_from_config", - lambda: "from-disk", + lambda _config_path=None: "from-disk", ) channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " live/model ") resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ) @@ -3711,7 +3713,7 @@ def test_bootstrap_prefers_runtime_model_name(bus: MagicMock, monkeypatch: pytes def test_bootstrap_falls_back_when_runtime_returns_empty(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( "nanobot.webui.ws_http._default_model_name_from_config", - lambda: "from-disk", + lambda _config_path=None: "from-disk", ) channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " ") resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ) @@ -3723,7 +3725,7 @@ def test_bootstrap_falls_back_when_runtime_returns_empty(bus: MagicMock, monkeyp def test_bootstrap_falls_back_when_runtime_raises(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( "nanobot.webui.ws_http._default_model_name_from_config", - lambda: "from-disk", + lambda _config_path=None: "from-disk", ) def boom(): diff --git a/nanobot/webui/mcp_presets_api.py b/nanobot/webui/mcp_presets_api.py index 753f4c5d1..4a20b76d2 100644 --- a/nanobot/webui/mcp_presets_api.py +++ b/nanobot/webui/mcp_presets_api.py @@ -502,10 +502,10 @@ def _known_preset_names() -> set[str]: return {preset.name for preset in MCP_PRESETS} -def _known_mcp_names() -> set[str]: +def _known_mcp_names(config_path: Path | None = None) -> set[str]: names = _known_preset_names() with suppress(Exception): - names.update(load_config().tools.mcp_servers) + names.update(load_config(config_path).tools.mcp_servers) return names @@ -518,11 +518,15 @@ def _clip_ws_string(value: Any, limit: int = 240) -> str | None: return text[:limit] -def normalize_mcp_preset_mentions(raw: Any) -> list[dict[str, Any]]: +def normalize_mcp_preset_mentions( + raw: Any, + *, + config_path: Path | None = None, +) -> list[dict[str, Any]]: """Sanitize structured MCP preset mentions sent by the WebUI.""" if not isinstance(raw, list): return [] - known = _known_mcp_names() + known = _known_mcp_names(config_path) out: list[dict[str, Any]] = [] seen: set[str] = set() for item_value in cast(list[object], raw)[:8]: diff --git a/nanobot/webui/settings_services.py b/nanobot/webui/settings_services.py index acd8204b7..5f635b1d4 100644 --- a/nanobot/webui/settings_services.py +++ b/nanobot/webui/settings_services.py @@ -8,6 +8,8 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, TypeVar +from filelock import FileLock + from nanobot.config.loader import load_config, save_config from nanobot.config.schema import Config @@ -16,11 +18,14 @@ _WEBUI_OAUTH_MAX_FLOWS = 8 class WebUISettingsConfig: - """Instance-scoped config access with serialized read-modify-write operations.""" + """Path-scoped config access with process-safe read-modify-write operations.""" def __init__(self, config_path: Path) -> None: self.path = config_path.expanduser().resolve(strict=False) + self.path.parent.mkdir(parents=True, exist_ok=True) self._lock = threading.RLock() + lock_path = self.path.with_suffix(f"{self.path.suffix}.lock") + self._file_lock = FileLock(str(lock_path)) def load(self) -> Config: """Load this gateway's config without consulting the process-global path.""" @@ -28,16 +33,16 @@ class WebUISettingsConfig: return load_config(self.path) def update(self, mutation: Callable[[Config], _T]) -> _T: - """Apply and atomically persist one in-process read-modify-write operation.""" - with self._lock: + """Apply and atomically persist one path-scoped read-modify-write operation.""" + with self._lock, self._file_lock: config = load_config(self.path) result = mutation(config) save_config(config, self.path) return result def run_serialized(self, operation: Callable[[Path], _T]) -> _T: - """Run a path-aware read-modify-write operation under the instance lock.""" - with self._lock: + """Run a path-aware read-modify-write operation under the config-file lock.""" + with self._lock, self._file_lock: return operation(self.path) diff --git a/nanobot/webui/skills_api.py b/nanobot/webui/skills_api.py index ef80a47d4..1fa636bbd 100644 --- a/nanobot/webui/skills_api.py +++ b/nanobot/webui/skills_api.py @@ -73,10 +73,11 @@ def set_webui_skill_enabled( *, enabled: bool, disabled_skills: set[str], + config_path: Path | None = None, ) -> dict[str, Any]: """Persist and apply one skill's enabled state.""" _require_skill_entry(workspace_path, name) - config = load_config() + config = load_config(config_path) next_disabled = set(config.agents.defaults.disabled_skills) if enabled: next_disabled.discard(name) @@ -84,7 +85,7 @@ def set_webui_skill_enabled( next_disabled.add(name) if next_disabled != set(config.agents.defaults.disabled_skills): config.agents.defaults.disabled_skills = sorted(next_disabled) - save_config(config) + save_config(config, config_path) disabled_skills.clear() disabled_skills.update(next_disabled) return {"name": name, "enabled": enabled, "deleted": False} @@ -95,6 +96,7 @@ def delete_webui_skill( name: str, *, disabled_skills: set[str], + config_path: Path | None = None, ) -> dict[str, Any]: """Delete one workspace skill and remove its disabled-state entry.""" entry = _require_skill_entry(workspace_path, name) @@ -116,7 +118,7 @@ def delete_webui_skill( if not target.is_symlink() and not target.is_dir(): raise SkillManagementError("skill directory was not found", status=404) - config = load_config() + config = load_config(config_path) original_disabled = list(config.agents.defaults.disabled_skills) next_disabled = set(original_disabled) if name in next_disabled: @@ -127,7 +129,7 @@ def delete_webui_skill( try: if next_disabled != set(original_disabled): config.agents.defaults.disabled_skills = sorted(next_disabled) - save_config(config) + save_config(config, config_path) except Exception: config.agents.defaults.disabled_skills = original_disabled staged_target.replace(target) diff --git a/nanobot/webui/transcription_ws.py b/nanobot/webui/transcription_ws.py index 8404206e1..a01a5303a 100644 --- a/nanobot/webui/transcription_ws.py +++ b/nanobot/webui/transcription_ws.py @@ -6,6 +6,7 @@ the WebUI-specific audio transcription action carried over that socket. from __future__ import annotations +from pathlib import Path from typing import Any from nanobot.audio.transcription import ( @@ -18,7 +19,11 @@ from nanobot.config.loader import load_config _MAX_REQUEST_ID_LENGTH = 80 -async def webui_transcription_event(envelope: dict[str, Any]) -> tuple[str, dict[str, Any]]: +async def webui_transcription_event( + envelope: dict[str, Any], + *, + config_path: Path | None = None, +) -> tuple[str, dict[str, Any]]: """Return the WS event name and payload for one WebUI transcription request.""" request_id = envelope.get("request_id") valid_request_id = ( @@ -38,7 +43,7 @@ async def webui_transcription_event(envelope: dict[str, Any]) -> tuple[str, dict try: text = await transcribe_audio_data_url( envelope.get("data_url"), - resolve_transcription_config(load_config()), + resolve_transcription_config(load_config(config_path)), duration_ms=envelope.get("duration_ms"), ) except TranscriptionIngressError as exc: diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 6f7d54439..ff4c383fd 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -254,10 +254,10 @@ def _request_query(request: WsRequest) -> dict[str, list[str]]: return query -def _default_model_name_from_config() -> str | None: +def _default_model_name_from_config(config_path: Path | None = None) -> str | None: try: from nanobot.config.loader import load_config - model = load_config().resolve_preset().model.strip() + model = load_config(config_path).resolve_preset().model.strip() return model or None except Exception as e: logger.debug("bootstrap model_name could not load from config: {}", e) @@ -266,6 +266,7 @@ def _default_model_name_from_config() -> str | None: def _resolve_bootstrap_model_name( runtime_name: Callable[[], str | None] | None, + config_path: Path | None = None, ) -> str: if runtime_name is not None: try: @@ -277,7 +278,7 @@ def _resolve_bootstrap_model_name( stripped = raw.strip() if stripped: return stripped - return _default_model_name_from_config() or "" + return _default_model_name_from_config(config_path) or "" # --------------------------------------------------------------------------- @@ -603,7 +604,10 @@ class GatewayHTTPHandler: "limits": self.ingress.bootstrap_limits( max_frame_bytes=self.config.max_message_bytes, ), - "model_name": _resolve_bootstrap_model_name(self.runtime_model_name), + "model_name": _resolve_bootstrap_model_name( + self.runtime_model_name, + self.settings.config.path, + ), "runtime_surface": self._runtime_surface, "runtime_capabilities": self._capabilities, } @@ -634,7 +638,10 @@ class GatewayHTTPHandler: "limits": self.ingress.bootstrap_limits( max_frame_bytes=self.config.max_message_bytes, ), - "model_name": _resolve_bootstrap_model_name(self.runtime_model_name), + "model_name": _resolve_bootstrap_model_name( + self.runtime_model_name, + self.settings.config.path, + ), "runtime_surface": self._runtime_surface, "runtime_capabilities": self._capabilities, } @@ -1236,9 +1243,9 @@ class GatewayHTTPHandler: if _is_local_browser_request(connection, request.headers): return True try: - from nanobot.config.loader import load_config - - return bool(load_config().tools.webui_allow_remote_package_install) + return bool( + self.settings.config.load().tools.webui_allow_remote_package_install + ) except Exception: self._log.exception("failed to load remote package install policy") return False @@ -1252,11 +1259,14 @@ class GatewayHTTPHandler: if raw_enabled not in {"true", "false"}: return _http_error(400, "enabled must be true or false") try: - action = set_webui_skill_enabled( - self.skills_workspace_path, - name, - enabled=raw_enabled == "true", - disabled_skills=self.disabled_skills, + action = self.settings.config.run_serialized( + lambda config_path: set_webui_skill_enabled( + self.skills_workspace_path, + name, + enabled=raw_enabled == "true", + disabled_skills=self.disabled_skills, + config_path=config_path, + ) ) except SkillManagementError as exc: return _http_error(exc.status, exc.message) @@ -1280,10 +1290,13 @@ class GatewayHTTPHandler: return _http_error(403, "remote skill deletion is disabled") name = _query_first(_request_query(request), "name") or "" try: - action = delete_webui_skill( - self.skills_workspace_path, - name, - disabled_skills=self.disabled_skills, + action = self.settings.config.run_serialized( + lambda config_path: delete_webui_skill( + self.skills_workspace_path, + name, + disabled_skills=self.disabled_skills, + config_path=config_path, + ) ) except SkillManagementError as exc: return _http_error(exc.status, exc.message) diff --git a/tests/webui/test_mcp_presets_api.py b/tests/webui/test_mcp_presets_api.py index f75d68cbe..56f1dfa98 100644 --- a/tests/webui/test_mcp_presets_api.py +++ b/tests/webui/test_mcp_presets_api.py @@ -11,6 +11,7 @@ from mcp.shared.auth import OAuthToken from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials from nanobot.config.loader import load_config, save_config +from nanobot.config.schema import Config from nanobot.webui.mcp_presets_api import ( McpPresetError, custom_mcp_action, @@ -722,3 +723,29 @@ def test_normalize_mcp_preset_mentions_accepts_configured_custom_server( ]) assert payload == [{"name": "docs", "display_name": "Docs", "transport": "streamableHttp"}] + + +def test_normalize_mcp_mentions_uses_explicit_gateway_config( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + default_path = tmp_path / "default.json" + config_path = tmp_path / "gateway.json" + save_config(Config(), default_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", default_path) + custom_mcp_action( + "custom", + { + "name": ["gateway-docs"], + "transport": ["streamableHttp"], + "url": ["https://example.com/mcp"], + }, + config_path=config_path, + ) + + payload = normalize_mcp_preset_mentions( + [{"name": "gateway-docs", "display_name": "Gateway docs"}], + config_path=config_path, + ) + + assert payload == [{"name": "gateway-docs", "display_name": "Gateway docs"}] diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 1cd63c11a..a4b471863 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -1466,7 +1466,10 @@ def test_settings_payload_includes_token_usage_summary( from nanobot.webui.token_usage import record_token_usage - record_token_usage({"prompt_tokens": 10, "completion_tokens": 5}) + record_token_usage( + {"prompt_tokens": 10, "completion_tokens": 5}, + timezone_name=config.agents.defaults.timezone, + ) payload = settings_payload() @@ -1491,7 +1494,10 @@ def test_settings_usage_payload_returns_lightweight_token_usage( from nanobot.webui.token_usage import record_token_usage - record_token_usage({"prompt_tokens": 20, "completion_tokens": 2}) + record_token_usage( + {"prompt_tokens": 20, "completion_tokens": 2}, + timezone_name=config.agents.defaults.timezone, + ) payload = settings_usage_payload() diff --git a/tests/webui/test_settings_services.py b/tests/webui/test_settings_services.py index 5ea45f447..a356fa1b5 100644 --- a/tests/webui/test_settings_services.py +++ b/tests/webui/test_settings_services.py @@ -1,6 +1,7 @@ from __future__ import annotations import threading +from collections.abc import Callable from pathlib import Path from unittest.mock import MagicMock @@ -81,6 +82,15 @@ def test_gateway_settings_services_isolate_config_paths_and_oauth_flows( assert second_flow.cancel_count == 0 +def test_settings_service_supports_a_new_config_directory(tmp_path: Path) -> None: + config_path = tmp_path / "new" / "nested" / "config.json" + + services = WebUISettingsServices.create(config_path) + services.mutate(update_api_settings, {"port": ["19001"]}) + + assert load_config(config_path).api.port == 19001 + + def test_settings_mutations_serialize_read_modify_write( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -142,6 +152,71 @@ def test_settings_mutations_serialize_read_modify_write( assert saved.api.host == "127.0.0.9" +def test_distinct_gateways_serialize_mutations_for_the_same_config( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + save_config(Config(), config_path) + first_services = WebUISettingsServices.create(config_path) + second_services = WebUISettingsServices.create(config_path) + first_loaded = threading.Event() + release_first = threading.Event() + second_loaded = threading.Event() + errors: list[BaseException] = [] + + from nanobot.webui import settings_api + + original_load = settings_api._load_settings_config + + def controlled_load(path: Path | None) -> Config: + config = original_load(path) + if threading.current_thread().name == "gateway-first": + first_loaded.set() + if not release_first.wait(timeout=2): + raise TimeoutError("timed out waiting to release first gateway") + elif threading.current_thread().name == "gateway-second": + second_loaded.set() + return config + + monkeypatch.setattr(settings_api, "_load_settings_config", controlled_load) + + def mutate( + services: WebUISettingsServices, + operation: Callable[..., object], + query: dict[str, list[str]], + ) -> None: + try: + services.mutate(operation, query) + except BaseException as exc: # noqa: BLE001 - re-raised in the test thread + errors.append(exc) + + first = threading.Thread( + target=mutate, + args=(first_services, update_agent_settings, {"timezone": ["Asia/Tokyo"]}), + name="gateway-first", + ) + second = threading.Thread( + target=mutate, + args=(second_services, update_api_settings, {"host": ["127.0.0.9"]}), + name="gateway-second", + ) + first.start() + assert first_loaded.wait(timeout=2) + second.start() + assert not second_loaded.wait(timeout=0.1) + release_first.set() + first.join(timeout=2) + second.join(timeout=2) + + assert not first.is_alive() + assert not second.is_alive() + assert not errors + saved = load_config(config_path) + assert saved.agents.defaults.timezone == "Asia/Tokyo" + assert saved.api.host == "127.0.0.9" + + def test_oauth_registry_preserves_expiry_capacity_completion_and_cancel() -> None: registry = WebUIOAuthFlowRegistry(max_flows=2) expired = _Flow(expired=True) diff --git a/tests/webui/test_skills_api.py b/tests/webui/test_skills_api.py index 1601dec1c..b326b57c8 100644 --- a/tests/webui/test_skills_api.py +++ b/tests/webui/test_skills_api.py @@ -3,6 +3,8 @@ from types import SimpleNamespace import pytest +from nanobot.config.loader import load_config, save_config +from nanobot.config.schema import Config from nanobot.webui.skills_api import ( SkillManagementError, delete_webui_skill, @@ -79,8 +81,11 @@ def test_set_webui_skill_enabled_persists_and_updates_runtime( _write_skill(tmp_path, "custom-skill") config = _config() saved: list[object] = [] - monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda: config) - monkeypatch.setattr("nanobot.webui.skills_api.save_config", saved.append) + monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda _path=None: config) + monkeypatch.setattr( + "nanobot.webui.skills_api.save_config", + lambda value, _path=None: saved.append(value), + ) disabled: set[str] = set() action = set_webui_skill_enabled( @@ -100,6 +105,30 @@ def test_set_webui_skill_enabled_persists_and_updates_runtime( assert saved == [config] +def test_skill_state_uses_explicit_gateway_config( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "workspace" + _write_skill(workspace, "custom-skill") + default_path = tmp_path / "default.json" + gateway_path = tmp_path / "gateway.json" + save_config(Config(), default_path) + save_config(Config(), gateway_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", default_path) + + set_webui_skill_enabled( + workspace, + "custom-skill", + enabled=False, + disabled_skills=set(), + config_path=gateway_path, + ) + + assert load_config(default_path).agents.defaults.disabled_skills == [] + assert load_config(gateway_path).agents.defaults.disabled_skills == ["custom-skill"] + + def test_delete_webui_skill_only_deletes_workspace_skills( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -107,8 +136,11 @@ def test_delete_webui_skill_only_deletes_workspace_skills( directory = _write_skill(tmp_path, "custom-skill") config = _config("custom-skill") saved: list[object] = [] - monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda: config) - monkeypatch.setattr("nanobot.webui.skills_api.save_config", saved.append) + monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda _path=None: config) + monkeypatch.setattr( + "nanobot.webui.skills_api.save_config", + lambda value, _path=None: saved.append(value), + ) disabled = {"custom-skill"} action = delete_webui_skill( @@ -158,9 +190,9 @@ def test_delete_webui_skill_restores_directory_when_config_save_fails( ) -> None: directory = _write_skill(tmp_path, "custom-skill") config = _config("custom-skill") - monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda: config) + monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda _path=None: config) - def fail_save(_config: object) -> None: + def fail_save(_config: object, _path: Path | None = None) -> None: raise OSError("disk full") monkeypatch.setattr("nanobot.webui.skills_api.save_config", fail_save) diff --git a/tests/webui/test_transcription_ws.py b/tests/webui/test_transcription_ws.py index 3cc3770f0..5cc7972ed 100644 --- a/tests/webui/test_transcription_ws.py +++ b/tests/webui/test_transcription_ws.py @@ -41,6 +41,34 @@ async def test_webui_transcribe_audio_rejects_unconfigured_provider( } +@pytest.mark.asyncio +async def test_webui_transcription_uses_explicit_gateway_config( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + default_path = tmp_path / "default.json" + gateway_path = tmp_path / "gateway.json" + default = Config() + default.transcription.provider = "groq" + default.providers.groq.api_key = "gsk-global" + gateway = Config() + gateway.transcription.provider = "groq" + save_config(default, default_path) + save_config(gateway, gateway_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", default_path) + + event, payload = await webui_transcription_event( + { + "request_id": "voice-explicit", + "data_url": _audio_data_url(), + }, + config_path=gateway_path, + ) + + assert event == "transcription_error" + assert payload["detail"] == "not_configured" + + @pytest.mark.asyncio async def test_webui_transcribe_audio_rejects_unsupported_mime( tmp_path,