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
+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,