mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
fix(code): keep auth modal interactive after install-on-select (#4187)
Fixed an unresponsive API-key prompt after installing a provider from the `/model` switcher. --- When the `/model` switcher installs a missing provider extra (e.g. `baseten`) and then prompts for an API key, the `AuthPromptScreen` was completely unresponsive — typing, `F2`, and clicks did nothing. The router `_handle_model_selection` scheduled `_install_extra_then_switch` via `call_later`, which runs the coroutine inline on the App message pump. That flow awaits the credential modal through `_push_screen_wait`, so the pump stayed blocked for the modal's entire lifetime and no key/mouse events were ever forwarded to it. The fix runs the install-then-switch flow in a Worker (a separate task) so the pump stays free, mirroring the existing thread-selector pattern; `call_after_refresh` lets the dismissing selector fully unwind before the worker starts. Made by [Open SWE](https://openswe.vercel.app/agents/abddcdb2-ea10-237c-747c-cb4a1f85d7f4) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -1887,6 +1887,9 @@ class DeepAgentsApp(App):
|
||||
self._last_model_unchanged_message: str | None = None
|
||||
"""Most recent same-model notice, used to suppress duplicates."""
|
||||
|
||||
self._model_install_switching = False
|
||||
"""True while a provider extra install-then-switch flow is active."""
|
||||
|
||||
self._message_timestamps_visible = _load_message_timestamps_visible()
|
||||
"""Whether message timestamp footers are shown in the chat surface.
|
||||
|
||||
@@ -9401,16 +9404,66 @@ class DeepAgentsApp(App):
|
||||
# install it first (with restart offer) before switching.
|
||||
extra = screen.pending_install_extra
|
||||
if extra:
|
||||
from functools import partial
|
||||
if self._model_install_switching:
|
||||
self.notify(
|
||||
"A provider install is already in progress. Try again after "
|
||||
"it finishes.",
|
||||
severity="warning",
|
||||
timeout=5,
|
||||
markup=False,
|
||||
)
|
||||
return
|
||||
|
||||
self.call_later(
|
||||
partial(
|
||||
self._install_extra_then_switch,
|
||||
extra,
|
||||
model_spec,
|
||||
extra_kwargs=extra_kwargs,
|
||||
),
|
||||
)
|
||||
# Set synchronously (before the worker is scheduled) so a second
|
||||
# selection on the same message pump is rejected by the guard above
|
||||
# before its own worker can start.
|
||||
self._model_install_switching = True
|
||||
|
||||
async def install_then_switch() -> None:
|
||||
try:
|
||||
await self._install_extra_then_switch(
|
||||
extra,
|
||||
model_spec,
|
||||
extra_kwargs=extra_kwargs,
|
||||
)
|
||||
finally:
|
||||
# Sole reset path once the worker awaits this coroutine; runs
|
||||
# on success, exception, and cancellation alike.
|
||||
self._model_install_switching = False
|
||||
|
||||
def start_install_worker() -> None:
|
||||
# Run in a worker, not via `call_later`. `_install_extra_then_switch`
|
||||
# awaits a credential modal (`AuthPromptScreen`); `call_later` would
|
||||
# invoke the coroutine inline on the App message pump, blocking it
|
||||
# for the modal's lifetime so no key/mouse input ever reaches the
|
||||
# prompt. A worker is a separate task, so the pump stays free and
|
||||
# the modal is interactive.
|
||||
#
|
||||
# The guard is reset only by the coroutine's `finally`, which runs
|
||||
# once the worker awaits it. If `run_worker` raises while
|
||||
# scheduling, the coroutine never starts, so reset the guard here
|
||||
# (and close the orphan coroutine) to keep a failed start from
|
||||
# stranding the guard `True` and blocking every later install. A
|
||||
# dropped `call_after_refresh` callback only happens at app
|
||||
# teardown, where a stuck guard is harmless.
|
||||
coro = install_then_switch()
|
||||
try:
|
||||
self.run_worker(
|
||||
coro,
|
||||
exclusive=False,
|
||||
group="model-install-switch",
|
||||
)
|
||||
except Exception:
|
||||
# Worker never started: close the orphan coroutine and
|
||||
# release the guard so the failed start can't strand it,
|
||||
# then re-raise (never swallow the scheduling error).
|
||||
coro.close()
|
||||
self._model_install_switching = False
|
||||
raise
|
||||
|
||||
# `call_after_refresh` lets the dismissing selector unwind before the
|
||||
# worker starts (mirrors the thread selector).
|
||||
self.call_after_refresh(start_install_worker)
|
||||
else:
|
||||
self._dispatch_model_switch(model_spec, extra_kwargs=extra_kwargs)
|
||||
|
||||
|
||||
@@ -7664,11 +7664,24 @@ class TestHandleModelSelection:
|
||||
"""Tests for the model-selector result router."""
|
||||
|
||||
async def test_install_extra_then_switch_when_pending_extra(self) -> None:
|
||||
"""A confirmed install routes through install-then-switch."""
|
||||
"""A confirmed install routes through install-then-switch in a worker.
|
||||
|
||||
Regression: scheduling via `call_later` runs the coroutine inline on the
|
||||
App message pump, which blocks for the lifetime of the awaited
|
||||
`AuthPromptScreen` so no input reaches the modal. The flow must run in a
|
||||
worker (a separate task) instead, with `call_after_refresh` letting the
|
||||
dismissing selector unwind first.
|
||||
"""
|
||||
app = DeepAgentsApp()
|
||||
call_after_refresh = MagicMock()
|
||||
app.call_after_refresh = call_after_refresh # ty: ignore
|
||||
app.call_later = MagicMock() # ty: ignore
|
||||
run_worker = MagicMock()
|
||||
app.run_worker = run_worker # ty: ignore
|
||||
dispatch = MagicMock()
|
||||
app._dispatch_model_switch = dispatch # ty: ignore
|
||||
install = AsyncMock()
|
||||
app._install_extra_then_switch = install # ty: ignore
|
||||
screen = SimpleNamespace(pending_install_extra="baseten")
|
||||
|
||||
app._handle_model_selection(
|
||||
@@ -7678,38 +7691,145 @@ class TestHandleModelSelection:
|
||||
)
|
||||
|
||||
dispatch.assert_not_called()
|
||||
app.call_later.assert_called_once() # ty: ignore
|
||||
scheduled = app.call_later.call_args.args[0] # ty: ignore
|
||||
# Bound methods compare equal (same instance + function) but each
|
||||
# attribute access yields a fresh object, so `==`, not `is`.
|
||||
assert scheduled.func == app._install_extra_then_switch
|
||||
assert scheduled.args == ("baseten", "baseten:moonshotai/Kimi-K2.6")
|
||||
assert scheduled.keywords == {"extra_kwargs": {"temperature": 0}}
|
||||
# The flow is never scheduled inline on the message pump.
|
||||
app.call_later.assert_not_called() # ty: ignore
|
||||
call_after_refresh.assert_called_once() # ty: ignore
|
||||
# Running the deferred callback starts the worker.
|
||||
run_worker.assert_not_called()
|
||||
assert app._model_install_switching is True
|
||||
call_after_refresh.call_args.args[0]()
|
||||
run_worker.assert_called_once()
|
||||
assert run_worker.call_args.kwargs.get("group") == "model-install-switch"
|
||||
assert run_worker.call_args.kwargs.get("exclusive") is False
|
||||
await run_worker.call_args.args[0]
|
||||
install.assert_awaited_once_with(
|
||||
"baseten",
|
||||
"baseten:moonshotai/Kimi-K2.6",
|
||||
extra_kwargs={"temperature": 0},
|
||||
)
|
||||
assert app._model_install_switching is False
|
||||
|
||||
async def test_install_then_switch_resets_guard_on_error(self) -> None:
|
||||
"""The in-progress guard resets even when the install raises.
|
||||
|
||||
The reset lives in the coroutine's `finally`, not a trailing assignment,
|
||||
precisely so a failed install can't strand the guard `True` and block
|
||||
every later install behind the "already in progress" notice.
|
||||
"""
|
||||
app = DeepAgentsApp()
|
||||
app.call_after_refresh = MagicMock() # ty: ignore
|
||||
run_worker = MagicMock()
|
||||
app.run_worker = run_worker # ty: ignore
|
||||
install = AsyncMock(side_effect=RuntimeError("install boom"))
|
||||
app._install_extra_then_switch = install # ty: ignore
|
||||
screen = SimpleNamespace(pending_install_extra="baseten")
|
||||
|
||||
app._handle_model_selection(
|
||||
screen, # ty: ignore
|
||||
("baseten:moonshotai/Kimi-K2.6", "baseten"),
|
||||
)
|
||||
|
||||
assert app._model_install_switching is True
|
||||
# Run the deferred callback to build and schedule the worker coroutine.
|
||||
app.call_after_refresh.call_args.args[0]() # ty: ignore
|
||||
with pytest.raises(RuntimeError, match="install boom"):
|
||||
await run_worker.call_args.args[0]
|
||||
assert app._model_install_switching is False
|
||||
|
||||
async def test_install_then_switch_resets_guard_on_scheduling_failure(
|
||||
self,
|
||||
) -> None:
|
||||
"""The guard resets when `run_worker` raises while scheduling.
|
||||
|
||||
This is the third leg of the guard lifecycle: if the worker never
|
||||
starts, the coroutine's `finally` never runs, so `start_install_worker`
|
||||
must close the orphan coroutine, release the guard, and re-raise. A
|
||||
failed start that stranded the guard `True` would block every later
|
||||
install behind the "already in progress" notice.
|
||||
"""
|
||||
app = DeepAgentsApp()
|
||||
app.call_after_refresh = MagicMock() # ty: ignore
|
||||
app.run_worker = MagicMock( # ty: ignore
|
||||
side_effect=RuntimeError("schedule boom"),
|
||||
)
|
||||
install = AsyncMock()
|
||||
app._install_extra_then_switch = install # ty: ignore
|
||||
screen = SimpleNamespace(pending_install_extra="baseten")
|
||||
|
||||
app._handle_model_selection(
|
||||
screen, # ty: ignore
|
||||
("baseten:moonshotai/Kimi-K2.6", "baseten"),
|
||||
)
|
||||
|
||||
assert app._model_install_switching is True
|
||||
# The deferred callback schedules the worker; `run_worker` raises, so
|
||||
# the scheduling error must propagate (never be swallowed).
|
||||
with pytest.raises(RuntimeError, match="schedule boom"):
|
||||
app.call_after_refresh.call_args.args[0]() # ty: ignore
|
||||
# Guard released so a later install can proceed.
|
||||
assert app._model_install_switching is False
|
||||
# The orphan coroutine was closed, never awaited, so the install body
|
||||
# never ran.
|
||||
install.assert_not_awaited()
|
||||
|
||||
async def test_pending_install_extra_does_not_start_concurrent_install(
|
||||
self,
|
||||
) -> None:
|
||||
"""A second provider install selection waits for the active one."""
|
||||
app = DeepAgentsApp()
|
||||
app._model_install_switching = True
|
||||
app.call_after_refresh = MagicMock() # ty: ignore
|
||||
app.run_worker = MagicMock() # ty: ignore
|
||||
app.notify = MagicMock() # ty: ignore
|
||||
dispatch = MagicMock()
|
||||
app._dispatch_model_switch = dispatch # ty: ignore
|
||||
screen = SimpleNamespace(pending_install_extra="baseten")
|
||||
|
||||
app._handle_model_selection(
|
||||
screen, # ty: ignore
|
||||
("baseten:moonshotai/Kimi-K2.6", "baseten"),
|
||||
)
|
||||
|
||||
app.notify.assert_called_once()
|
||||
assert app.notify.call_args.kwargs.get("severity") == "warning"
|
||||
# `markup=False` matches the `notify()` convention used for these
|
||||
# operational notices: the text is shown literally rather than parsed
|
||||
# as Textual console markup.
|
||||
assert app.notify.call_args.kwargs.get("markup") is False
|
||||
app.call_after_refresh.assert_not_called() # ty: ignore
|
||||
app.run_worker.assert_not_called() # ty: ignore
|
||||
dispatch.assert_not_called()
|
||||
# The early return must not clear the in-flight flow's guard.
|
||||
assert app._model_install_switching is True
|
||||
|
||||
async def test_plain_switch_when_no_pending_extra(self) -> None:
|
||||
"""No pending extra dispatches a normal model switch."""
|
||||
app = DeepAgentsApp()
|
||||
app.call_later = MagicMock() # ty: ignore
|
||||
app.call_after_refresh = MagicMock() # ty: ignore
|
||||
app.run_worker = MagicMock() # ty: ignore
|
||||
dispatch = MagicMock()
|
||||
app._dispatch_model_switch = dispatch # ty: ignore
|
||||
screen = SimpleNamespace(pending_install_extra=None)
|
||||
|
||||
app._handle_model_selection(screen, ("openai:gpt-5.5", "openai")) # ty: ignore
|
||||
|
||||
app.call_later.assert_not_called() # ty: ignore
|
||||
app.call_after_refresh.assert_not_called() # ty: ignore
|
||||
app.run_worker.assert_not_called() # ty: ignore
|
||||
dispatch.assert_called_once_with("openai:gpt-5.5", extra_kwargs=None)
|
||||
|
||||
async def test_cancelled_selection_is_noop(self) -> None:
|
||||
"""A `None` result neither switches nor installs."""
|
||||
app = DeepAgentsApp()
|
||||
app.call_later = MagicMock() # ty: ignore
|
||||
app.call_after_refresh = MagicMock() # ty: ignore
|
||||
app.run_worker = MagicMock() # ty: ignore
|
||||
dispatch = MagicMock()
|
||||
app._dispatch_model_switch = dispatch # ty: ignore
|
||||
screen = SimpleNamespace(pending_install_extra="baseten")
|
||||
|
||||
app._handle_model_selection(screen, None) # ty: ignore
|
||||
|
||||
app.call_later.assert_not_called() # ty: ignore
|
||||
app.call_after_refresh.assert_not_called() # ty: ignore
|
||||
app.run_worker.assert_not_called() # ty: ignore
|
||||
dispatch.assert_not_called()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user