fix(settings): serialize gateway configuration updates

This commit is contained in:
Xubin Ren
2026-08-16 11:50:56 +08:00
parent 731b8fc2ed
commit dec89a49a3
13 changed files with 347 additions and 75 deletions
+42 -28
View File
@@ -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():
+8 -4
View File
@@ -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]:
+10 -5
View File
@@ -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)
+6 -4
View File
@@ -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)
+7 -2
View File
@@ -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
View File
@@ -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)
+27
View File
@@ -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"}]
+8 -2
View File
@@ -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()
+75
View File
@@ -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)
+38 -6
View File
@@ -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)
+28
View File
@@ -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,