mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
fix(code): show loading state during model switch (#4209)
Show an animated loading indicator in the status bar while switching models, so slow provider switches no longer appear to hang. --- Switching to a heavy provider (e.g. `google_genai`) imports a large package during model creation, which previously gave no feedback and made the switch look frozen. The heavy work already runs off the event loop in a worker thread, so this adds the missing visual feedback rather than changing the threading model. A shared animated busy indicator is added to `StatusBar` (`set_busy`) that reuses the existing connection spinner machinery, and `_switch_model` drives it around the threaded model creation. The indicator is cleared in a `finally` block so the status slot can never get stuck after a failed or successful switch, and regular status messages are restored once it clears. Made by [Open SWE](https://openswe.vercel.app/agents/c4a420f3-6b03-f87a-cf70-eb26b4366b09) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -15935,6 +15935,12 @@ class DeepAgentsApp(App):
|
||||
if provider and not parsed:
|
||||
display = f"{provider}:{model_name}"
|
||||
|
||||
# Provider package imports (e.g. langchain_google_genai) can take a
|
||||
# noticeable moment; show an animated busy indicator so it doesn't look
|
||||
# frozen. The work itself already runs off the event loop via
|
||||
# `asyncio.to_thread`, so the UI stays responsive meanwhile.
|
||||
if self._status_bar:
|
||||
self._status_bar.set_busy("Switching model")
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
_create_model_with_deepagents_import_lock,
|
||||
@@ -15954,6 +15960,9 @@ class DeepAgentsApp(App):
|
||||
ErrorMessage(_build_model_switch_error_body(exc)),
|
||||
)
|
||||
return
|
||||
finally:
|
||||
if self._status_bar:
|
||||
self._status_bar.set_busy("")
|
||||
|
||||
# Set the model override for ConfigurableModelMiddleware.
|
||||
# The next stream call passes CLIContext via context= and the
|
||||
|
||||
@@ -263,6 +263,7 @@ class StatusBar(Horizontal):
|
||||
self._hide_git_branch = is_env_truthy(HIDE_GIT_BRANCH)
|
||||
self._spinner = Spinner()
|
||||
self._spinner_timer: Timer | None = None
|
||||
self._busy_message = ""
|
||||
|
||||
def compose(self) -> ComposeResult: # noqa: PLR6301 — Textual widget method
|
||||
"""Compose the status bar layout.
|
||||
@@ -391,6 +392,10 @@ class StatusBar(Horizontal):
|
||||
|
||||
def watch_status_message(self, new_value: str) -> None:
|
||||
"""Update status message display."""
|
||||
if self._busy_message:
|
||||
# The busy indicator owns the status-message slot while active;
|
||||
# defer regular status updates until `set_busy("")` clears it.
|
||||
return
|
||||
try:
|
||||
msg_widget = self.query_one("#status-message", Static)
|
||||
except NoMatches:
|
||||
@@ -404,20 +409,32 @@ class StatusBar(Horizontal):
|
||||
else:
|
||||
msg_widget.update("")
|
||||
|
||||
def watch_connection_state(self, new_value: ConnectionState) -> None:
|
||||
def watch_connection_state(self, _new_value: ConnectionState) -> None:
|
||||
"""Start or stop the spinner and re-render when connection state changes."""
|
||||
if new_value in {"connecting", "reconnecting", "resuming"}:
|
||||
self._start_spinner()
|
||||
else:
|
||||
self._stop_spinner()
|
||||
self._sync_spinner()
|
||||
self._render_connection()
|
||||
|
||||
def watch_queued_count(self, _new_value: int) -> None:
|
||||
"""Re-render the connection indicator when the queued count changes."""
|
||||
self._render_connection()
|
||||
|
||||
def _spinner_active(self) -> bool:
|
||||
"""Whether any indicator (connection or busy) needs the shared spinner.
|
||||
|
||||
Returns:
|
||||
`True` when a connection state or a busy message is active.
|
||||
"""
|
||||
return bool(self.connection_state) or bool(self._busy_message)
|
||||
|
||||
def _sync_spinner(self) -> None:
|
||||
"""Start or stop the shared spinner to match connection/busy state."""
|
||||
if self._spinner_active():
|
||||
self._start_spinner()
|
||||
else:
|
||||
self._stop_spinner()
|
||||
|
||||
def _start_spinner(self) -> None:
|
||||
"""Begin cycling the connection-indicator spinner frames.
|
||||
"""Begin cycling the shared spinner frames.
|
||||
|
||||
No-op when not yet running (e.g. before mount) since `set_interval`
|
||||
requires a live event loop, or when an animation is already active.
|
||||
@@ -436,9 +453,10 @@ class StatusBar(Horizontal):
|
||||
self._spinner = Spinner()
|
||||
|
||||
def _tick_spinner(self) -> None:
|
||||
"""Advance the spinner frame and re-render the connection indicator."""
|
||||
"""Advance the spinner frame and re-render the animated indicators."""
|
||||
self._spinner.next_frame()
|
||||
self._render_connection()
|
||||
self._render_busy()
|
||||
|
||||
def _render_connection(self) -> None:
|
||||
"""Render the combined connection + queued-count indicator text."""
|
||||
@@ -464,6 +482,36 @@ class StatusBar(Horizontal):
|
||||
widget.display = bool(text)
|
||||
widget.update(text)
|
||||
|
||||
def _render_busy(self) -> None:
|
||||
"""Render the animated busy indicator into the status-message slot."""
|
||||
if not self._busy_message:
|
||||
return
|
||||
try:
|
||||
widget = self.query_one("#status-message", Static)
|
||||
except NoMatches:
|
||||
return
|
||||
widget.remove_class("thinking")
|
||||
frame = self._spinner.current_frame()
|
||||
widget.update(Content.assemble(frame, " ", Content(self._busy_message)))
|
||||
|
||||
def set_busy(self, message: str) -> None:
|
||||
"""Show or clear an animated busy indicator in the status-message slot.
|
||||
|
||||
Reuses the shared status-bar spinner so heavier UI operations (e.g. a
|
||||
model switch that imports a provider package) show activity instead of
|
||||
appearing to hang.
|
||||
|
||||
Args:
|
||||
message: Busy text to animate with a spinner, or empty string to
|
||||
clear it and restore the regular status message.
|
||||
"""
|
||||
self._busy_message = message
|
||||
self._sync_spinner()
|
||||
if message:
|
||||
self._render_busy()
|
||||
else:
|
||||
self.watch_status_message(self.status_message)
|
||||
|
||||
def set_connection(self, state: ConnectionState) -> None:
|
||||
"""Set the connection indicator state.
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from textual.app import App, ComposeResult
|
||||
|
||||
from deepagents_code import model_config
|
||||
from deepagents_code.app import (
|
||||
@@ -22,6 +23,7 @@ from deepagents_code.model_config import (
|
||||
)
|
||||
from deepagents_code.remote_client import RemoteAgent
|
||||
from deepagents_code.widgets.messages import AppMessage, ErrorMessage
|
||||
from deepagents_code.widgets.status import StatusBar
|
||||
|
||||
_CONFIGURED_AUTH_STATUS = ProviderAuthStatus(
|
||||
state=ProviderAuthState.CONFIGURED,
|
||||
@@ -1219,3 +1221,120 @@ class TestModelCommandIntegration:
|
||||
|
||||
assert len(captured_errors) == 1
|
||||
assert "cannot be used with --default" in captured_errors[0]
|
||||
|
||||
|
||||
class _StatusBarHarness(App[None]):
|
||||
"""Minimal app that mounts a `StatusBar` so its child widgets exist.
|
||||
|
||||
`_switch_model`'s success path calls `set_model`, which queries the
|
||||
`#model-display` child, so the bar must be mounted to be driven end-to-end.
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Yield a single status bar."""
|
||||
yield StatusBar(id="status-bar")
|
||||
|
||||
|
||||
class TestModelSwitchBusyIndicator:
|
||||
"""Tests that `_switch_model` drives the status-bar busy indicator.
|
||||
|
||||
These use a real mounted `StatusBar` rather than a mock so the wiring is
|
||||
verified against actual `set_busy` behavior end-to-end.
|
||||
"""
|
||||
|
||||
async def test_busy_set_during_switch_and_cleared_on_success(
|
||||
self, mock_create_model: Mock
|
||||
) -> None:
|
||||
"""Busy shows "Switching model" mid-flight and clears after success."""
|
||||
app = DeepAgentsApp()
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
app._agent = _make_remote_agent()
|
||||
|
||||
settings.model_name = "gpt-5.5"
|
||||
settings.model_provider = "openai"
|
||||
|
||||
async with _StatusBarHarness().run_test() as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
app._status_bar = bar
|
||||
|
||||
busy_mid_flight: list[str] = []
|
||||
|
||||
def capture_busy(
|
||||
model_spec: str,
|
||||
*,
|
||||
extra_kwargs: dict[str, object] | None = None,
|
||||
profile_overrides: dict[str, object] | None = None,
|
||||
) -> _FakeModelResult:
|
||||
# Runs in the worker thread: record what the bar shows while the
|
||||
# (normally slow) provider import is notionally in progress.
|
||||
del model_spec, extra_kwargs, profile_overrides
|
||||
busy_mid_flight.append(bar._busy_message)
|
||||
return _FakeModelResult(
|
||||
model_name="claude-sonnet-4-5",
|
||||
provider="anthropic",
|
||||
context_limit=200_000,
|
||||
)
|
||||
|
||||
mock_create_model.side_effect = capture_busy
|
||||
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.model_config.get_provider_auth_status",
|
||||
return_value=_CONFIGURED_AUTH_STATUS,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.model_config.save_recent_model",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
await app._switch_model("anthropic:claude-sonnet-4-5")
|
||||
|
||||
assert busy_mid_flight == ["Switching model"]
|
||||
assert bar._busy_message == ""
|
||||
|
||||
async def test_busy_cleared_when_switch_fails(
|
||||
self, mock_create_model: Mock
|
||||
) -> None:
|
||||
"""A failed model creation must still clear busy via the `finally` block.
|
||||
|
||||
Regression guard: if busy were cleared only on the success path, a
|
||||
failed switch would leave the status bar spinning "Switching model"
|
||||
indefinitely. The clear lives in a `finally`, so it must run here too.
|
||||
"""
|
||||
app = DeepAgentsApp()
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
app._agent = _make_remote_agent()
|
||||
|
||||
settings.model_name = "gpt-5.5"
|
||||
settings.model_provider = "openai"
|
||||
|
||||
async with _StatusBarHarness().run_test() as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
app._status_bar = bar
|
||||
|
||||
busy_mid_flight: list[str] = []
|
||||
|
||||
def fail_after_capture(
|
||||
model_spec: str,
|
||||
*,
|
||||
extra_kwargs: dict[str, object] | None = None,
|
||||
profile_overrides: dict[str, object] | None = None,
|
||||
) -> _FakeModelResult:
|
||||
del model_spec, extra_kwargs, profile_overrides
|
||||
busy_mid_flight.append(bar._busy_message)
|
||||
msg = "provider import blew up"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
mock_create_model.side_effect = fail_after_capture
|
||||
|
||||
with patch(
|
||||
"deepagents_code.model_config.get_provider_auth_status",
|
||||
return_value=_CONFIGURED_AUTH_STATUS,
|
||||
):
|
||||
await app._switch_model("anthropic:claude-sonnet-4-5")
|
||||
|
||||
# Busy showed while the switch ran, then cleared despite the failure.
|
||||
assert busy_mid_flight == ["Switching model"]
|
||||
assert bar._busy_message == ""
|
||||
# The failure surfaced to the user rather than being swallowed.
|
||||
app._mount_message.assert_called_once() # ty: ignore
|
||||
|
||||
@@ -622,6 +622,121 @@ class TestConnectionIndicator:
|
||||
assert bar._spinner_timer is None
|
||||
|
||||
|
||||
class TestBusyIndicator:
|
||||
"""Tests for the animated busy indicator used during model switches."""
|
||||
|
||||
async def test_set_busy_shows_message_and_spinner(self) -> None:
|
||||
"""`set_busy` should render a spinner-prefixed message and run the timer."""
|
||||
from deepagents_code.config import get_glyphs
|
||||
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.set_busy("Switching model")
|
||||
await pilot.pause()
|
||||
msg = pilot.app.query_one("#status-message", Static)
|
||||
rendered = str(msg.render())
|
||||
assert "Switching model" in rendered
|
||||
# A spinner frame prefixes the message. Don't pin frame[0]: the
|
||||
# 0.1s timer may have ticked during the pause, so accept any frame.
|
||||
assert any(frame in rendered for frame in get_glyphs().spinner_frames)
|
||||
assert bar._spinner_timer is not None
|
||||
|
||||
async def test_set_busy_treats_bracket_text_as_literal(self) -> None:
|
||||
"""A model spec with markup-like brackets must render verbatim, not crash."""
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.set_busy("Switching to openai:[00]")
|
||||
await pilot.pause()
|
||||
msg = pilot.app.query_one("#status-message", Static)
|
||||
assert "Switching to openai:[00]" in str(msg.render())
|
||||
|
||||
async def test_clear_busy_stops_spinner_and_clears_message(self) -> None:
|
||||
"""Clearing the busy state should stop the timer and empty the slot."""
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.set_busy("Switching model")
|
||||
await pilot.pause()
|
||||
bar.set_busy("")
|
||||
await pilot.pause()
|
||||
msg = pilot.app.query_one("#status-message", Static)
|
||||
assert str(msg.render()) == ""
|
||||
assert bar._spinner_timer is None
|
||||
|
||||
async def test_clear_busy_restores_status_message(self) -> None:
|
||||
"""A status message set before busy should reappear once busy clears."""
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.set_status_message("Thinking")
|
||||
await pilot.pause()
|
||||
bar.set_busy("Switching model")
|
||||
await pilot.pause()
|
||||
msg = pilot.app.query_one("#status-message", Static)
|
||||
assert "Switching" in str(msg.render())
|
||||
bar.set_busy("")
|
||||
await pilot.pause()
|
||||
assert str(msg.render()) == "Thinking"
|
||||
|
||||
async def test_status_message_deferred_while_busy(self) -> None:
|
||||
"""Regular status updates must not clobber an active busy indicator."""
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.set_busy("Switching model")
|
||||
await pilot.pause()
|
||||
bar.set_status_message("Executing")
|
||||
await pilot.pause()
|
||||
msg = pilot.app.query_one("#status-message", Static)
|
||||
assert "Switching" in str(msg.render())
|
||||
|
||||
async def test_busy_keeps_spinner_running_while_connecting(self) -> None:
|
||||
"""Clearing busy while connecting must leave the shared spinner running."""
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.set_connection("connecting")
|
||||
bar.set_busy("Switching model")
|
||||
await pilot.pause()
|
||||
assert bar._spinner_timer is not None
|
||||
bar.set_busy("")
|
||||
await pilot.pause()
|
||||
assert bar._spinner_timer is not None
|
||||
|
||||
async def test_clear_busy_while_connecting_restores_message(self) -> None:
|
||||
"""Clearing busy mid-connection restores the message and keeps the spinner.
|
||||
|
||||
Exercises the combined state the shared-spinner refactor targets: the
|
||||
busy slot and the independent connection indicator are both active, and
|
||||
clearing busy must repaint the deferred message without stopping the
|
||||
spinner that the still-active connection state owns.
|
||||
"""
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.set_connection("connecting")
|
||||
bar.set_status_message("Thinking")
|
||||
bar.set_busy("Switching model")
|
||||
await pilot.pause()
|
||||
msg = pilot.app.query_one("#status-message", Static)
|
||||
conn = pilot.app.query_one("#connection-indicator", Static)
|
||||
# Busy owns the message slot; the connection indicator is separate.
|
||||
assert "Switching model" in str(msg.render())
|
||||
assert "Connecting" in str(conn.render())
|
||||
bar.set_busy("")
|
||||
await pilot.pause()
|
||||
# Deferred message reappears; spinner keeps ticking for the
|
||||
# still-active connection state.
|
||||
assert str(msg.render()) == "Thinking"
|
||||
assert "Connecting" in str(conn.render())
|
||||
assert bar._spinner_timer is not None
|
||||
|
||||
async def test_set_busy_before_mount_does_not_raise(self) -> None:
|
||||
"""`set_busy` on an unmounted bar is a safe no-op (no widgets, no timer)."""
|
||||
bar = StatusBar()
|
||||
bar.set_busy("Switching model")
|
||||
assert bar._busy_message == "Switching model"
|
||||
assert bar._spinner_timer is None
|
||||
bar.set_busy("")
|
||||
assert bar._busy_message == ""
|
||||
assert bar._spinner_timer is None
|
||||
|
||||
|
||||
class TestQueuedCount:
|
||||
"""Tests for the queued-message count in the connection indicator."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user