mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
fix(settings): serialize gateway configuration updates
This commit is contained in:
@@ -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