mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 10:11:46 +03:00
fix(models): synchronize canonical runtime selection
This commit is contained in:
@@ -226,7 +226,7 @@ def test_named_default_refresh_is_used_by_sessions_without_override(tmp_path: Pa
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_invalidation_notifies_clients_before_session_runtime_refresh(
|
||||
async def test_config_invalidation_defers_canonical_notification_until_default_refresh(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
provider = _provider("model-a")
|
||||
@@ -266,12 +266,59 @@ async def test_config_invalidation_notifies_clients_before_session_runtime_refre
|
||||
runtime = loop.runtime_for_session(session)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert [(event.model, event.model_preset) for event in published] == [
|
||||
("model-a", "fast"),
|
||||
]
|
||||
assert published == []
|
||||
assert runtime.model == "model-b"
|
||||
assert loop.model_presets["fast"].model == "model-b"
|
||||
|
||||
assert loop.llm_runtime().model == "model-b"
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert [(event.model, event.model_preset) for event in published] == [
|
||||
("model-b", "fast"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_refresh_publishes_renamed_canonical_preset(tmp_path: Path) -> None:
|
||||
provider = _provider("model-a")
|
||||
catalog = {"fast": ModelPresetConfig(model="model-a")}
|
||||
default_name = "fast"
|
||||
published: list[RuntimeModelChanged] = []
|
||||
|
||||
def load_preset(name: str) -> ProviderSnapshot:
|
||||
return ProviderSnapshot(
|
||||
provider=provider,
|
||||
model=catalog[name].model,
|
||||
context_window_tokens=16_000,
|
||||
signature=(name, catalog[name].model),
|
||||
model_preset=name,
|
||||
)
|
||||
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="model-a",
|
||||
context_window_tokens=16_000,
|
||||
provider_signature=("fast", "model-a"),
|
||||
provider_snapshot_loader=lambda: load_preset(default_name),
|
||||
model_presets=catalog,
|
||||
preset_catalog_loader=lambda: catalog,
|
||||
model_preset="fast",
|
||||
preset_snapshot_loader=load_preset,
|
||||
)
|
||||
loop.runtime_events.subscribe(published.append, RuntimeModelChanged)
|
||||
catalog["Codex"] = catalog.pop("fast")
|
||||
default_name = "Codex"
|
||||
|
||||
runtime = loop.refresh_runtime_config()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert (runtime.model, runtime.model_preset) == ("model-a", "Codex")
|
||||
assert [(event.model, event.model_preset) for event in published] == [
|
||||
("model-a", "Codex"),
|
||||
]
|
||||
|
||||
|
||||
def test_next_turn_captures_generation_changed_after_previous_admission(
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -10,6 +10,7 @@ from nanobot.bus.runtime_events import (
|
||||
SessionTurnStarted,
|
||||
TurnCompleted,
|
||||
TurnRunStatusChanged,
|
||||
TurnRuntimeAdmitted,
|
||||
)
|
||||
|
||||
|
||||
@@ -123,6 +124,37 @@ async def test_runtime_event_publisher_consumes_turn_metadata_on_complete() -> N
|
||||
assert second.runtime is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_event_publisher_exposes_admitted_runtime() -> None:
|
||||
bus = RuntimeEventBus()
|
||||
seen: list[object] = []
|
||||
publisher = RuntimeEventPublisher(bus)
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="chat-a",
|
||||
content="hello",
|
||||
)
|
||||
runtime = object()
|
||||
bus.subscribe(seen.append)
|
||||
|
||||
await publisher.turn_runtime_admitted(msg, "websocket:chat-a", runtime) # type: ignore[arg-type]
|
||||
await publisher.turn_completed(
|
||||
channel="websocket",
|
||||
chat_id="chat-a",
|
||||
session_key="websocket:chat-a",
|
||||
metadata=None,
|
||||
)
|
||||
|
||||
admitted = seen[0]
|
||||
completed = seen[1]
|
||||
assert isinstance(admitted, TurnRuntimeAdmitted)
|
||||
assert admitted.runtime is runtime
|
||||
assert admitted.context.chat_id == "chat-a"
|
||||
assert isinstance(completed, TurnCompleted)
|
||||
assert completed.runtime is runtime
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_event_publisher_emits_persisted_turn_attributes() -> None:
|
||||
bus = RuntimeEventBus()
|
||||
|
||||
@@ -7,7 +7,11 @@ import pytest
|
||||
from nanobot.agent.tools.context import RequestContext, request_context
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.outbound_events import GoalStatusEvent, TurnModelUpdatedEvent
|
||||
from nanobot.bus.runtime_events import RuntimeEventBus, RuntimeEventContext, TurnRuntimeAdmitted
|
||||
from nanobot.providers.base import GenerationSettings
|
||||
from nanobot.session import webui_turns as wth
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY
|
||||
|
||||
|
||||
@@ -144,10 +148,18 @@ async def test_fallback_model_is_scoped_to_its_websocket_chat() -> None:
|
||||
bus.publish_outbound = AsyncMock()
|
||||
observer = wth.build_webui_fallback_model_observer(bus)
|
||||
|
||||
runtime = LLMRuntime(
|
||||
provider=MagicMock(),
|
||||
model="openai/gpt-4.1",
|
||||
generation=GenerationSettings(),
|
||||
context_window_tokens=16_000,
|
||||
model_preset="Deep Research",
|
||||
)
|
||||
with request_context(
|
||||
RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="chat-model",
|
||||
runtime=runtime,
|
||||
metadata={"webui": True},
|
||||
)
|
||||
):
|
||||
@@ -159,6 +171,46 @@ async def test_fallback_model_is_scoped_to_its_websocket_chat() -> None:
|
||||
assert outbound.metadata == {"webui": True}
|
||||
assert isinstance(outbound.event, TurnModelUpdatedEvent)
|
||||
assert outbound.event.model == "deepseek/deepseek-chat"
|
||||
assert outbound.event.model_preset == "Deep Research"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admitted_runtime_publishes_chat_scoped_model_and_preset(tmp_path) -> None:
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
runtime_events = RuntimeEventBus()
|
||||
coordinator = wth.WebuiTurnCoordinator(
|
||||
bus=bus,
|
||||
sessions=SessionManager(tmp_path),
|
||||
schedule_background=lambda coro: coro.close(),
|
||||
)
|
||||
coordinator.subscribe(runtime_events)
|
||||
runtime = LLMRuntime(
|
||||
provider=MagicMock(),
|
||||
model="openai-codex/gpt-5.6",
|
||||
generation=GenerationSettings(),
|
||||
context_window_tokens=262_144,
|
||||
model_preset="Codex",
|
||||
)
|
||||
|
||||
await runtime_events.publish(
|
||||
TurnRuntimeAdmitted(
|
||||
context=RuntimeEventContext(
|
||||
channel="websocket",
|
||||
chat_id="chat-model",
|
||||
session_key="websocket:chat-model",
|
||||
metadata={"webui": True},
|
||||
),
|
||||
runtime=runtime,
|
||||
)
|
||||
)
|
||||
|
||||
outbound = bus.publish_outbound.await_args.args[0]
|
||||
assert outbound.channel == "websocket"
|
||||
assert outbound.chat_id == "chat-model"
|
||||
assert isinstance(outbound.event, TurnModelUpdatedEvent)
|
||||
assert outbound.event.model == "openai-codex/gpt-5.6"
|
||||
assert outbound.event.model_preset == "Codex"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -307,6 +307,18 @@ async def test_oauth_completion_reads_websocket_payload(
|
||||
@pytest.mark.parametrize(
|
||||
("route_path", "function_name", "payload", "expected_query"),
|
||||
[
|
||||
(
|
||||
"/api/settings/update",
|
||||
"update_agent_settings",
|
||||
{"model_preset": "Codex"},
|
||||
{"model_preset": ["Codex"]},
|
||||
),
|
||||
(
|
||||
"/api/settings/model-configurations/create",
|
||||
"create_model_configuration",
|
||||
{"name": "Codex", "model": "openai-codex/gpt-5.6"},
|
||||
{"name": ["Codex"], "model": ["openai-codex/gpt-5.6"]},
|
||||
),
|
||||
(
|
||||
"/api/settings/model-configurations/delete",
|
||||
"delete_model_configuration",
|
||||
@@ -325,10 +337,22 @@ async def test_oauth_completion_reads_websocket_payload(
|
||||
{"order": ["backup"]},
|
||||
{"order": ['["backup"]']},
|
||||
),
|
||||
(
|
||||
"/api/settings/provider/create",
|
||||
"create_provider_settings",
|
||||
{"name": "team", "api_base": "https://llm.example/v1"},
|
||||
{"name": ["team"], "api_base": ["https://llm.example/v1"]},
|
||||
),
|
||||
(
|
||||
"/api/settings/provider/update",
|
||||
"update_provider_settings",
|
||||
{"provider": "team", "api_base": "https://llm.example/v2"},
|
||||
{"provider": ["team"], "api_base": ["https://llm.example/v2"]},
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_preset_mutation_routes(
|
||||
async def test_runtime_config_mutation_routes_refresh_live_runtime(
|
||||
monkeypatch,
|
||||
route_path: str,
|
||||
function_name: str,
|
||||
@@ -336,6 +360,7 @@ async def test_model_preset_mutation_routes(
|
||||
expected_query: dict[str, list[str]],
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
refresh_runtime_config = MagicMock()
|
||||
|
||||
def mutate(query, *, config_path=None):
|
||||
captured["query"] = query
|
||||
@@ -344,12 +369,15 @@ async def test_model_preset_mutation_routes(
|
||||
monkeypatch.setattr(f"nanobot.webui.settings_routes.{function_name}", mutate)
|
||||
request = _mutation_request(route_path, payload)
|
||||
|
||||
response = await _router().dispatch(None, request, route_path)
|
||||
response = await _router(
|
||||
refresh_runtime_config=refresh_runtime_config,
|
||||
).dispatch(None, request, route_path)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body)["routed"] == function_name
|
||||
assert captured["query"] == expected_query
|
||||
refresh_runtime_config.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user