mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
fix(settings): serialize gateway configuration updates
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
+30
-17
@@ -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)
|
||||
|
||||
@@ -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"}]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user