feat(webui): show the actual fallback model (#5017)
This commit is contained in:
@@ -81,6 +81,13 @@ class RuntimeModelUpdatedEvent(OutboundEvent):
|
||||
model_preset: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TurnModelUpdatedEvent(OutboundEvent):
|
||||
"""The fallback model currently handling one chat turn."""
|
||||
|
||||
model: str
|
||||
|
||||
|
||||
def outbound_message_for_event(
|
||||
*,
|
||||
channel: str,
|
||||
|
||||
@@ -26,6 +26,7 @@ from nanobot.bus.outbound_events import (
|
||||
RuntimeModelUpdatedEvent,
|
||||
SessionUpdatedEvent,
|
||||
TurnEndEvent,
|
||||
TurnModelUpdatedEvent,
|
||||
outbound_event_from_message,
|
||||
outbound_message_for_event,
|
||||
)
|
||||
@@ -319,7 +320,7 @@ class WebSocketChannel(BaseChannel):
|
||||
await self.send_goal_status(chat_id, "running", started_at=t0)
|
||||
|
||||
async def _hydrate_after_subscribe(self, chat_id: str) -> None:
|
||||
"""Replay goal/run strip state after subscribe (same-process refresh)."""
|
||||
"""Replay persisted or actively running per-chat state after subscribe."""
|
||||
await self._maybe_push_active_goal_state(chat_id)
|
||||
await self._maybe_push_turn_run_wall_clock(chat_id)
|
||||
|
||||
@@ -805,6 +806,13 @@ class WebSocketChannel(BaseChannel):
|
||||
self.logger.debug("no active subscribers for chat_id={}", msg.chat_id)
|
||||
else:
|
||||
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
|
||||
if isinstance(event, TurnModelUpdatedEvent):
|
||||
if conns:
|
||||
await self.send_turn_model_updated(
|
||||
msg.chat_id,
|
||||
model_name=event.model,
|
||||
)
|
||||
return
|
||||
if isinstance(event, GoalStateSyncEvent):
|
||||
if conns:
|
||||
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
|
||||
@@ -1113,3 +1121,26 @@ class WebSocketChannel(BaseChannel):
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" runtime_model_updated ")
|
||||
|
||||
async def send_turn_model_updated(
|
||||
self,
|
||||
chat_id: str,
|
||||
*,
|
||||
model_name: Any,
|
||||
) -> None:
|
||||
"""Notify one chat's subscribers which model is handling its current request."""
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
if (
|
||||
not conns
|
||||
or not isinstance(model_name, str)
|
||||
or not model_name.strip()
|
||||
):
|
||||
return
|
||||
body: dict[str, Any] = {
|
||||
"event": "turn_model_updated",
|
||||
"chat_id": chat_id,
|
||||
"model_name": model_name.strip(),
|
||||
}
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" turn_model_updated ")
|
||||
|
||||
@@ -20,6 +20,7 @@ from nanobot.bus.outbound_events import (
|
||||
RuntimeModelUpdatedEvent,
|
||||
SessionUpdatedEvent,
|
||||
TurnEndEvent,
|
||||
TurnModelUpdatedEvent,
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.websocket.runtime import (
|
||||
@@ -1061,6 +1062,33 @@ async def test_send_broadcasts_runtime_model_updates() -> None:
|
||||
assert payload["model_preset"] == "fast"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
|
||||
bus = MessageBus()
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
|
||||
chat_one = AsyncMock()
|
||||
chat_two = AsyncMock()
|
||||
channel._attach(chat_one, "chat-1")
|
||||
channel._attach(chat_two, "chat-2")
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content="",
|
||||
event=TurnModelUpdatedEvent(model="deepseek/deepseek-chat"),
|
||||
)
|
||||
)
|
||||
|
||||
payload = json.loads(chat_one.send.call_args.args[0])
|
||||
assert payload == {
|
||||
"event": "turn_model_updated",
|
||||
"chat_id": "chat-1",
|
||||
"model_name": "deepseek/deepseek-chat",
|
||||
}
|
||||
chat_two.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_model_update_publisher_uses_websocket_outbound_event() -> None:
|
||||
bus = MessageBus()
|
||||
|
||||
@@ -14,6 +14,7 @@ async def test_hydrate_after_subscribe_is_quiet_when_no_turn_active():
|
||||
channel.gateway = MagicMock()
|
||||
channel.gateway.session_manager = MagicMock()
|
||||
channel.gateway.session_manager.read_session_file = MagicMock(return_value={})
|
||||
channel._turn_models = {}
|
||||
|
||||
sent_events = []
|
||||
|
||||
@@ -39,6 +40,7 @@ async def test_hydrate_after_subscribe_pushes_running_when_turn_active():
|
||||
channel.gateway = MagicMock()
|
||||
channel.gateway.session_manager = MagicMock()
|
||||
channel.gateway.session_manager.read_session_file = MagicMock(return_value={})
|
||||
channel._turn_models = {}
|
||||
|
||||
sent_events = []
|
||||
|
||||
|
||||
+18
-3
@@ -1617,9 +1617,14 @@ def _run_gateway(
|
||||
from nanobot.cron.session_turns import is_bound_cron_job
|
||||
from nanobot.cron.types import CronJob
|
||||
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
|
||||
from nanobot.providers.fallback_provider import FallbackProvider
|
||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.webui_turns import WebuiTurnCoordinator, WebuiTurnRoutePolicy
|
||||
from nanobot.session.webui_turns import (
|
||||
WebuiTurnCoordinator,
|
||||
WebuiTurnRoutePolicy,
|
||||
build_webui_fallback_model_observer,
|
||||
)
|
||||
from nanobot.triggers.local_runner import run_local_trigger_queue
|
||||
from nanobot.triggers.local_store import LocalTriggerStore
|
||||
from nanobot.webui.token_usage import TokenUsageHook
|
||||
@@ -1651,8 +1656,18 @@ def _run_gateway(
|
||||
sync_workspace_templates(config.workspace_path)
|
||||
bus = MessageBus()
|
||||
runtime_events = RuntimeEventBus()
|
||||
fallback_model_observer = build_webui_fallback_model_observer(bus)
|
||||
|
||||
def _observe_fallback_models(snapshot):
|
||||
if isinstance(snapshot.provider, FallbackProvider):
|
||||
snapshot.provider.set_fallback_model_observer(fallback_model_observer)
|
||||
return snapshot
|
||||
|
||||
def _load_gateway_provider_snapshot(*args: Any, **kwargs: Any):
|
||||
return _observe_fallback_models(load_provider_snapshot(*args, **kwargs))
|
||||
|
||||
try:
|
||||
provider_snapshot = build_provider_snapshot(config)
|
||||
provider_snapshot = _observe_fallback_models(build_provider_snapshot(config))
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
raise typer.Exit(1) from exc
|
||||
@@ -1696,7 +1711,7 @@ def _run_gateway(
|
||||
cron_service=cron,
|
||||
session_manager=session_manager,
|
||||
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||
provider_snapshot_loader=load_provider_snapshot,
|
||||
provider_snapshot_loader=_load_gateway_provider_snapshot,
|
||||
preset_catalog_loader=load_model_preset_catalog,
|
||||
runtime_events=runtime_events,
|
||||
turn_delivery_factory=turn_delivery_factory,
|
||||
|
||||
@@ -82,6 +82,9 @@ _FALLBACK_ERROR_TOKENS = (
|
||||
)
|
||||
|
||||
|
||||
FallbackModelObserver = Callable[[str], Awaitable[None]]
|
||||
|
||||
|
||||
class FallbackProvider(LLMProvider):
|
||||
"""Wrap a primary provider and transparently failover to fallback models.
|
||||
|
||||
@@ -108,10 +111,12 @@ class FallbackProvider(LLMProvider):
|
||||
primary: LLMProvider,
|
||||
fallback_presets: list[Any],
|
||||
provider_factory: Callable[[Any], LLMProvider],
|
||||
fallback_model_observer: FallbackModelObserver | None = None,
|
||||
):
|
||||
self._primary = primary
|
||||
self._fallback_presets = list(fallback_presets)
|
||||
self._provider_factory = provider_factory
|
||||
self._fallback_model_observer = fallback_model_observer
|
||||
self._has_fallbacks = bool(fallback_presets)
|
||||
self._primary_failures = 0
|
||||
self._primary_tripped_at: float | None = None
|
||||
@@ -127,6 +132,10 @@ class FallbackProvider(LLMProvider):
|
||||
def get_default_model(self) -> str:
|
||||
return self._primary.get_default_model()
|
||||
|
||||
def set_fallback_model_observer(self, observer: FallbackModelObserver | None) -> None:
|
||||
"""Attach a process-level observer without changing request call signatures."""
|
||||
self._fallback_model_observer = observer
|
||||
|
||||
@property
|
||||
def supports_progress_deltas(self) -> bool:
|
||||
return bool(getattr(self._primary, "supports_progress_deltas", False))
|
||||
@@ -268,6 +277,8 @@ class FallbackProvider(LLMProvider):
|
||||
)
|
||||
continue
|
||||
|
||||
await self._notify_fallback_model(fallback_model)
|
||||
|
||||
original_values = {
|
||||
name: kwargs.get(name, _MISSING)
|
||||
for name in ("model", "max_tokens", "temperature", "reasoning_effort")
|
||||
@@ -315,6 +326,14 @@ class FallbackProvider(LLMProvider):
|
||||
finish_reason="error",
|
||||
)
|
||||
|
||||
async def _notify_fallback_model(self, model: str) -> None:
|
||||
if self._fallback_model_observer is None:
|
||||
return
|
||||
try:
|
||||
await self._fallback_model_observer(model)
|
||||
except Exception:
|
||||
logger.exception("fallback model observer failed for '{}'", model)
|
||||
|
||||
@staticmethod
|
||||
def _should_fallback(response: LLMResponse) -> bool:
|
||||
if LLMProvider.is_arrearage_response(response):
|
||||
|
||||
@@ -11,6 +11,7 @@ from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.tools.context import current_request_context
|
||||
from nanobot.agent.turn_delivery import TurnRoute
|
||||
from nanobot.bus import progress as bus_progress
|
||||
from nanobot.bus.events import InboundMessage
|
||||
@@ -20,6 +21,7 @@ from nanobot.bus.outbound_events import (
|
||||
RuntimeModelUpdatedEvent,
|
||||
SessionUpdatedEvent,
|
||||
TurnEndEvent,
|
||||
TurnModelUpdatedEvent,
|
||||
outbound_message_for_event,
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
@@ -33,6 +35,7 @@ from nanobot.bus.runtime_events import (
|
||||
TurnRunStatusChanged,
|
||||
)
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.providers.fallback_provider import FallbackModelObserver
|
||||
from nanobot.runtime_context import public_history_message
|
||||
from nanobot.session.goal_state import goal_state_ws_blob
|
||||
from nanobot.session.history_visibility import is_hidden_history_message
|
||||
@@ -272,6 +275,28 @@ class WebuiTurnRoutePolicy:
|
||||
return replace(route, metadata=metadata, publish_lifecycle=True)
|
||||
|
||||
|
||||
def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserver:
|
||||
"""Translate provider fallback choices into chat-scoped WebUI events."""
|
||||
|
||||
async def _publish(model: str) -> None:
|
||||
context = current_request_context()
|
||||
if context is None or context.channel != "websocket":
|
||||
return
|
||||
chat_id = str(context.chat_id or "").strip()
|
||||
if not chat_id:
|
||||
return
|
||||
await bus.publish_outbound(
|
||||
outbound_message_for_event(
|
||||
channel=context.channel,
|
||||
chat_id=chat_id,
|
||||
event=TurnModelUpdatedEvent(model=model),
|
||||
metadata=context.metadata,
|
||||
)
|
||||
)
|
||||
|
||||
return _publish
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebuiTurnCoordinator:
|
||||
"""Translate generic runtime events into WebUI/WebSocket wire messages."""
|
||||
|
||||
@@ -285,6 +285,30 @@ class TestFallbackOnPrimaryError:
|
||||
assert primary.chat_calls[0]["model"] == "primary-model"
|
||||
assert fallback.chat_calls[0]["model"] == "fallback-a"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reports_the_fallback_model_before_its_request(self) -> None:
|
||||
primary = _FakeProvider("primary", _error_response())
|
||||
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
|
||||
fallback_models: list[str] = []
|
||||
|
||||
async def _observe(model: str) -> None:
|
||||
fallback_models.append(model)
|
||||
|
||||
fb = FallbackProvider(
|
||||
primary=primary,
|
||||
fallback_presets=[_fallback("fallback-a", provider="backup")],
|
||||
provider_factory=MagicMock(return_value=fallback),
|
||||
fallback_model_observer=_observe,
|
||||
)
|
||||
|
||||
result = await fb.chat_with_retry(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
model="primary-model",
|
||||
)
|
||||
|
||||
assert result.content == "fallback ok"
|
||||
assert fallback_models == ["fallback-a"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logs_primary_error_before_fallback(self) -> None:
|
||||
primary = _FakeProvider("primary", _error_response("primary overloaded"))
|
||||
|
||||
@@ -4,8 +4,9 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.context import RequestContext, request_context
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.outbound_events import GoalStatusEvent
|
||||
from nanobot.bus.outbound_events import GoalStatusEvent, TurnModelUpdatedEvent
|
||||
from nanobot.session import webui_turns as wth
|
||||
|
||||
|
||||
@@ -69,3 +70,38 @@ async def test_publish_turn_run_status_non_websocket_noop_registry() -> None:
|
||||
await wth.publish_turn_run_status(bus, msg, "running")
|
||||
|
||||
assert wth._WEBSOCKET_TURN_WALL_STARTED_AT == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_model_is_scoped_to_its_websocket_chat() -> None:
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
observer = wth.build_webui_fallback_model_observer(bus)
|
||||
|
||||
with request_context(
|
||||
RequestContext(
|
||||
channel="websocket",
|
||||
chat_id="chat-model",
|
||||
metadata={"webui": True},
|
||||
)
|
||||
):
|
||||
await observer("deepseek/deepseek-chat")
|
||||
|
||||
outbound = bus.publish_outbound.await_args.args[0]
|
||||
assert outbound.channel == "websocket"
|
||||
assert outbound.chat_id == "chat-model"
|
||||
assert outbound.metadata == {"webui": True}
|
||||
assert isinstance(outbound.event, TurnModelUpdatedEvent)
|
||||
assert outbound.event.model == "deepseek/deepseek-chat"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_model_ignores_non_websocket_requests() -> None:
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
observer = wth.build_webui_fallback_model_observer(bus)
|
||||
|
||||
with request_context(RequestContext(channel="telegram", chat_id="chat-model")):
|
||||
await observer("fallback")
|
||||
|
||||
bus.publish_outbound.assert_not_awaited()
|
||||
|
||||
@@ -170,6 +170,7 @@ interface ThreadComposerProps {
|
||||
modelProvider?: string | null;
|
||||
modelProviderLabel?: string | null;
|
||||
modelNeedsSetup?: boolean;
|
||||
fallbackModelName?: string | null;
|
||||
onModelBadgeClick?: () => void;
|
||||
variant?: "thread" | "hero";
|
||||
slashCommands?: SlashCommand[];
|
||||
@@ -815,6 +816,7 @@ export function ThreadComposer({
|
||||
modelProvider = null,
|
||||
modelProviderLabel = null,
|
||||
modelNeedsSetup = false,
|
||||
fallbackModelName = null,
|
||||
onModelBadgeClick,
|
||||
variant = "thread",
|
||||
slashCommands = [],
|
||||
@@ -2073,6 +2075,7 @@ export function ThreadComposer({
|
||||
provider={modelProvider}
|
||||
providerLabel={modelProviderLabel}
|
||||
needsSetup={modelNeedsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
isHero={isHero}
|
||||
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
|
||||
/>
|
||||
@@ -2361,6 +2364,7 @@ function ComposerModelBadge({
|
||||
provider,
|
||||
providerLabel,
|
||||
needsSetup,
|
||||
fallbackModelName,
|
||||
isHero,
|
||||
onClick,
|
||||
}: {
|
||||
@@ -2368,6 +2372,7 @@ function ComposerModelBadge({
|
||||
provider?: string | null;
|
||||
providerLabel?: string | null;
|
||||
needsSetup?: boolean;
|
||||
fallbackModelName?: string | null;
|
||||
isHero: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
@@ -2381,11 +2386,12 @@ function ComposerModelBadge({
|
||||
|
||||
return (
|
||||
<Container
|
||||
title={title}
|
||||
data-fallback={fallbackModelName ? "true" : undefined}
|
||||
title={fallbackModelName || title}
|
||||
type={interactive ? "button" : undefined}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
|
||||
"composer-model-badge inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
|
||||
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
|
||||
interactive && "cursor-pointer hover:bg-accent/55 hover:text-foreground",
|
||||
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
|
||||
|
||||
@@ -333,6 +333,7 @@ export function ThreadShell({
|
||||
forkBoundaryMessageCount,
|
||||
} = useSessionHistory(historyKey);
|
||||
const { client, ingressLimits, modelName, token } = useClient();
|
||||
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
||||
const [booting, setBooting] = useState(false);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const cliApps = useInstalledSettingItems({
|
||||
@@ -379,6 +380,7 @@ export function ThreadShell({
|
||||
return messageCacheRef.current.get(chatId) ?? historical;
|
||||
}, [chatId, historical]);
|
||||
const handleTurnEnd = useCallback(() => {
|
||||
setFallbackModelName(null);
|
||||
onTurnEnd?.();
|
||||
}, [onTurnEnd]);
|
||||
const {
|
||||
@@ -519,6 +521,18 @@ export function ThreadShell({
|
||||
});
|
||||
}, [client, refreshModelSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) {
|
||||
setFallbackModelName(null);
|
||||
return;
|
||||
}
|
||||
setFallbackModelName(null);
|
||||
return client.onChat(chatId, (event) => {
|
||||
if (event.event !== "turn_model_updated") return;
|
||||
setFallbackModelName(event.model_name);
|
||||
});
|
||||
}, [chatId, client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId || loading) return;
|
||||
const cached = messageCacheRef.current.get(chatId);
|
||||
@@ -680,6 +694,7 @@ export function ThreadShell({
|
||||
|
||||
const handleThreadSend = useCallback(
|
||||
(content: string, images?: SendAttachment[], options?: SendOptions) => {
|
||||
setFallbackModelName(null);
|
||||
setScrollToLatestUserPromptSignal((value) => value + 1);
|
||||
send(content, images, withWorkspaceScope(options));
|
||||
},
|
||||
@@ -808,6 +823,7 @@ export function ThreadShell({
|
||||
modelProvider={modelBadge.provider}
|
||||
modelProviderLabel={modelBadge.providerLabel}
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
slashCommands={slashCommands}
|
||||
@@ -845,6 +861,7 @@ export function ThreadShell({
|
||||
modelProvider={modelBadge.provider}
|
||||
modelProviderLabel={modelBadge.providerLabel}
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant="hero"
|
||||
slashCommands={slashCommands}
|
||||
|
||||
@@ -124,6 +124,38 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.composer-model-badge {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.composer-model-badge::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-color: rgb(236 141 49);
|
||||
opacity: 0;
|
||||
transition: opacity 600ms ease-in-out;
|
||||
}
|
||||
|
||||
.composer-model-badge[data-fallback="true"]::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.composer-model-badge > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.composer-model-badge::before {
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
}
|
||||
|
||||
.host-drag-region {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
@@ -1087,6 +1087,11 @@ export type InboundEvent =
|
||||
model_name: string;
|
||||
model_preset?: string | null;
|
||||
}
|
||||
| {
|
||||
event: "turn_model_updated";
|
||||
chat_id: string;
|
||||
model_name: string;
|
||||
}
|
||||
| ({
|
||||
event: "turn_end";
|
||||
chat_id: string;
|
||||
|
||||
@@ -357,6 +357,30 @@ describe("NanobotClient", () => {
|
||||
expect(handler).toHaveBeenCalledWith("openai/gpt-4.1", "fast");
|
||||
});
|
||||
|
||||
it("dispatches turn model updates to the active chat", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const chatHandler = vi.fn();
|
||||
client.onChat("chat-a", chatHandler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "turn_model_updated",
|
||||
chat_id: "chat-a",
|
||||
model_name: "deepseek/deepseek-chat",
|
||||
});
|
||||
|
||||
expect(chatHandler).toHaveBeenCalledWith({
|
||||
event: "turn_model_updated",
|
||||
chat_id: "chat-a",
|
||||
model_name: "deepseek/deepseek-chat",
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches session updates globally", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
@@ -14,13 +14,23 @@ const HERO_GREETING_PATTERN =
|
||||
function makeClient() {
|
||||
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
||||
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
|
||||
const runtimeModelHandlers = new Set<
|
||||
(modelName: string | null, modelPreset?: string | null) => void
|
||||
>();
|
||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
|
||||
return {
|
||||
status: "open" as const,
|
||||
defaultChatId: null as string | null,
|
||||
onStatus: () => () => {},
|
||||
onRuntimeModelUpdate: () => () => {},
|
||||
onRuntimeModelUpdate: (
|
||||
handler: (modelName: string | null, modelPreset?: string | null) => void,
|
||||
) => {
|
||||
runtimeModelHandlers.add(handler);
|
||||
return () => {
|
||||
runtimeModelHandlers.delete(handler);
|
||||
};
|
||||
},
|
||||
getRunStartedAt: () => null,
|
||||
getGoalState: (chatId: string) => goalStateByChatId.get(chatId),
|
||||
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
|
||||
@@ -55,6 +65,9 @@ function makeClient() {
|
||||
}
|
||||
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
|
||||
},
|
||||
_emitRuntimeModelUpdate(modelName: string | null, modelPreset?: string | null) {
|
||||
for (const h of runtimeModelHandlers) h(modelName, modelPreset);
|
||||
},
|
||||
_emitSessionUpdate(chatId: string, scope?: string) {
|
||||
for (const h of sessionUpdateHandlers) h(chatId, scope);
|
||||
},
|
||||
@@ -411,6 +424,66 @@ describe("ThreadShell", () => {
|
||||
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights the configured model badge without replacing the preset label", async () => {
|
||||
const client = makeClient();
|
||||
render(wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("fallback-model")}
|
||||
title="Fallback model"
|
||||
onToggleSidebar={() => {}}
|
||||
settingsSnapshot={modelSettings("openai-codex/gpt-5.5", "openai_codex")}
|
||||
/>,
|
||||
"openai-codex/gpt-5.5",
|
||||
));
|
||||
|
||||
expect(await screen.findByText("gpt-5.5")).toBeInTheDocument();
|
||||
const configuredBadge = screen.getByTestId("composer-model-logo-openai_codex").parentElement;
|
||||
expect(configuredBadge).not.toBeNull();
|
||||
expect(configuredBadge).toHaveClass("composer-model-badge");
|
||||
expect(configuredBadge).not.toHaveAttribute("data-fallback");
|
||||
|
||||
act(() => {
|
||||
client._emitChat("fallback-model", {
|
||||
event: "turn_model_updated",
|
||||
chat_id: "fallback-model",
|
||||
model_name: "deepseek/deepseek-chat",
|
||||
});
|
||||
});
|
||||
|
||||
const logo = screen.getByTestId("composer-model-logo-openai_codex");
|
||||
const badge = logo.parentElement;
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge).toBe(configuredBadge);
|
||||
expect(screen.getByText("gpt-5.5")).toBeInTheDocument();
|
||||
expect(screen.queryByText("deepseek-chat")).not.toBeInTheDocument();
|
||||
expect(badge).toHaveAttribute("data-fallback", "true");
|
||||
expect(badge).toHaveAttribute(
|
||||
"title",
|
||||
"deepseek/deepseek-chat",
|
||||
);
|
||||
expect(logo).not.toHaveAttribute("data-fallback");
|
||||
|
||||
act(() => {
|
||||
client._emitChat("fallback-model", {
|
||||
event: "turn_end",
|
||||
chat_id: "fallback-model",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
|
||||
).not.toHaveAttribute("data-fallback");
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
|
||||
).toHaveAttribute("title", "gpt-5.5 · OpenAI Codex");
|
||||
expect(
|
||||
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
|
||||
).toBe(badge);
|
||||
});
|
||||
|
||||
it("opens model settings from the unconfigured model badge", async () => {
|
||||
const client = makeClient();
|
||||
const settings = modelSettings("openai-codex/gpt-5.1-codex", "openai_codex");
|
||||
|
||||
Reference in New Issue
Block a user