diff --git a/nanobot/bus/outbound_events.py b/nanobot/bus/outbound_events.py index ed582c35..3b03068e 100644 --- a/nanobot/bus/outbound_events.py +++ b/nanobot/bus/outbound_events.py @@ -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, diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index 2e013c31..6741ad43 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -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 ") diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index 935203ba..934bc5a6 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -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() diff --git a/nanobot/channels/websocket/tests/test_websocket_reconnect_idle.py b/nanobot/channels/websocket/tests/test_websocket_reconnect_idle.py index bcdd41d0..eddd2bea 100644 --- a/nanobot/channels/websocket/tests/test_websocket_reconnect_idle.py +++ b/nanobot/channels/websocket/tests/test_websocket_reconnect_idle.py @@ -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 = [] diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index d96c92c8..dbc1bfb7 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -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, diff --git a/nanobot/providers/fallback_provider.py b/nanobot/providers/fallback_provider.py index 9eb70802..93f2550f 100644 --- a/nanobot/providers/fallback_provider.py +++ b/nanobot/providers/fallback_provider.py @@ -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): diff --git a/nanobot/session/webui_turns.py b/nanobot/session/webui_turns.py index d8683aed..d9ae091d 100644 --- a/nanobot/session/webui_turns.py +++ b/nanobot/session/webui_turns.py @@ -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.""" diff --git a/tests/agent/test_runner_fallback.py b/tests/agent/test_runner_fallback.py index c296709d..bf401d7d 100644 --- a/tests/agent/test_runner_fallback.py +++ b/tests/agent/test_runner_fallback.py @@ -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")) diff --git a/tests/utils/test_webui_turn_helpers.py b/tests/utils/test_webui_turn_helpers.py index 3f7a2b21..c019de30 100644 --- a/tests/utils/test_webui_turn_helpers.py +++ b/tests/utils/test_webui_turn_helpers.py @@ -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() diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index ef65f2ec..139aed2e 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -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 ( (null); const [booting, setBooting] = useState(false); const [slashCommands, setSlashCommands] = useState([]); 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} diff --git a/webui/src/globals.css b/webui/src/globals.css index 30435135..bf4c4899 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -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; } diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index ad873c26..5450ef75 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -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; diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts index ae140bf3..f60eef97 100644 --- a/webui/src/tests/nanobot-client.test.ts +++ b/webui/src/tests/nanobot-client.test.ts @@ -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", diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 8563e166..8946ef28 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -14,13 +14,23 @@ const HERO_GREETING_PATTERN = function makeClient() { const errorHandlers = new Set<(err: { kind: string }) => void>(); const chatHandlers = new Map 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(); 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, + {}} + 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");