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:
Mason Daugherty
2026-06-23 18:21:51 -04:00
committed by GitHub
parent 47f6057370
commit afbc56a1d9
2 changed files with 194 additions and 21 deletions
+62 -9
View File
@@ -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)
+132 -12
View File
@@ -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()