feat(code): auto-retry credentials-blocked startup after /auth (#4176)

The interactive coding agent now restarts automatically after you add a
missing provider API key via `/auth`, instead of requiring a manual
`/restart`.

---

When the langgraph server fails to start with a
`MissingCredentialsError` (e.g. a missing `ANTHROPIC_API_KEY`), `/auth`
is the natural place to supply the key. Previously the user then had to
type `/restart` (or `/model`) to actually bring the server up — closing
the `/auth` modal did nothing. Now closing `/auth` auto-retries startup
once the blocking provider's credentials resolve, mirroring the existing
deferred first-launch behavior. The retry reuses
`_retry_startup_with_model` and is a no-op when no credentials-blocked
failure is pending or the key is still missing (so it can't loop back
into the same failure).

Made by [Open
SWE](https://openswe.vercel.app/agents/038952da-70d6-65f8-2237-6b4828df49a5)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-06-23 21:18:25 -04:00
committed by GitHub
parent 785c8d0061
commit d10ba3dc16
4 changed files with 385 additions and 2 deletions
+68 -1
View File
@@ -9753,12 +9753,79 @@ class DeepAgentsApp(App):
partial(self._install_provider_then_reopen_auth, extra),
)
return
task = asyncio.create_task(self._maybe_start_deferred_server_from_default())
task = asyncio.create_task(self._resume_server_after_auth_change())
task.add_done_callback(_log_task_exception)
screen = AuthManagerScreen()
self.push_screen(screen, handle_result)
def on_auth_manager_screen_credential_saved(self, event: Message) -> None:
"""Retry credentials-blocked startup immediately after `/auth` saves a key."""
event.stop()
task = asyncio.create_task(self._resume_server_after_auth_change())
task.add_done_callback(_log_task_exception)
async def _resume_server_after_auth_change(self) -> None:
"""Bring the server up after `/auth` if a credential now unblocks it.
Two cases close on the same key entry: a deferred first launch (no
credentials at startup) and a startup that failed with
`MissingCredentialsError`. Try the deferred path first; if it doesn't
apply, retry a credentials-blocked startup.
"""
if await self._maybe_start_deferred_server_from_default():
return
await self._maybe_retry_startup_after_auth_change()
async def _maybe_retry_startup_after_auth_change(self) -> bool:
"""Retry a credentials-blocked startup once `/auth` adds the key.
After the server fails to start with `MissingCredentialsError`, `/auth`
is the natural place to supply the missing key. Rather than make the
user type `/restart` afterward, retry startup automatically once the
blocking provider's credentials resolve.
Returns:
`True` when a startup retry was kicked off, otherwise `False`.
"""
provider = self._server_startup_missing_credentials_provider
if (
self._server_startup_error is None
or provider is None
or self._server_kwargs is None
):
return False
from deepagents_code.model_config import get_provider_auth_status
auth_status = get_provider_auth_status(provider)
if auth_status.blocks_start:
# Key still missing — don't loop back into the same failure.
return False
model_spec = self._server_kwargs.get("model_name")
if not model_spec:
from deepagents_code.config import _get_default_model_spec
from deepagents_code.model_config import (
ModelConfigError,
NoCredentialsConfiguredError,
)
try:
model_spec = _get_default_model_spec()
except NoCredentialsConfiguredError:
# No usable default to fall back to — nothing to retry.
return False
except ModelConfigError as exc:
# Malformed config is actionable; surface it instead of
# silently doing nothing after the user closes `/auth`.
await self._mount_message(ErrorMessage(str(exc)))
return False
extra_kwargs = self._server_kwargs.get("model_params")
await self._retry_startup_with_model(model_spec, extra_kwargs=extra_kwargs)
return True
async def _install_provider_then_reopen_auth(self, extra: str) -> None:
"""Install a provider's extra from `/auth`, then reopen the manager.
+7 -1
View File
@@ -27,6 +27,7 @@ from textual.binding import Binding, BindingType
from textual.color import Color as TColor
from textual.containers import Vertical
from textual.content import Content
from textual.message import Message
from textual.screen import ModalScreen
from textual.style import Style as TStyle
from textual.widgets import Input, OptionList, Static
@@ -959,6 +960,9 @@ class AuthManagerScreen(ModalScreen[None]):
the model selector's install-on-select flow) and reopen the manager.
"""
class CredentialSaved(Message):
"""Posted when a key prompt successfully persists credentials."""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "cancel", "Close", show=False, priority=True),
Binding("tab", "cursor_down", "Next", show=False, priority=True),
@@ -1227,9 +1231,11 @@ class AuthManagerScreen(ModalScreen[None]):
"""Move the option-list cursor up."""
self.query_one("#auth-manager-options", OptionList).action_cursor_up()
def _on_prompt_closed(self, _result: AuthResult | None) -> None:
def _on_prompt_closed(self, result: AuthResult | None) -> None:
"""Refresh the option list once the prompt dismisses."""
self._refresh_options()
if result is AuthResult.SAVED:
self.post_message(self.CredentialSaved())
def _refresh_options(self) -> None:
"""Rebuild option labels from current store state."""
+276
View File
@@ -8789,6 +8789,282 @@ class TestDeferredActions:
assert app._server_startup_missing_provider_package is None
assert app._server_startup_missing_credentials_provider == "openai"
async def test_auth_saved_event_resumes_startup_immediately(self) -> None:
"""Saving a key in `/auth` retries without waiting for the manager to close."""
from deepagents_code.widgets.auth import AuthManagerScreen
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
resume = AsyncMock()
app._resume_server_after_auth_change = resume # ty: ignore
app.on_auth_manager_screen_credential_saved(
AuthManagerScreen.CredentialSaved()
)
await asyncio.sleep(0)
resume.assert_awaited_once_with()
async def test_auth_change_retries_credentials_blocked_startup(self) -> None:
"""Adding the missing key via `/auth` auto-retries a failed startup.
The user shouldn't have to type `/restart` after entering credentials
for the provider that blocked startup.
"""
from deepagents_code.model_config import (
ProviderAuthSource,
ProviderAuthState,
ProviderAuthStatus,
)
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._server_kwargs = {
"model_name": "anthropic:claude-opus-4-7",
"model_params": {"temperature": 0.1},
}
app._server_startup_error = "missing ANTHROPIC_API_KEY"
app._server_startup_missing_credentials_provider = "anthropic"
app._retry_startup_with_model = AsyncMock() # ty: ignore
with patch(
"deepagents_code.model_config.get_provider_auth_status",
return_value=ProviderAuthStatus(
state=ProviderAuthState.CONFIGURED,
provider="anthropic",
source=ProviderAuthSource.STORED,
),
):
retried = await app._maybe_retry_startup_after_auth_change()
assert retried is True
app._retry_startup_with_model.assert_awaited_once_with( # ty: ignore
"anthropic:claude-opus-4-7",
extra_kwargs={"temperature": 0.1},
)
async def test_auth_change_does_not_retry_when_key_still_missing(self) -> None:
"""A still-missing key must not loop back into the same failure."""
from deepagents_code.model_config import (
ProviderAuthState,
ProviderAuthStatus,
)
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._server_kwargs = {"model_name": "anthropic:claude-opus-4-7"}
app._server_startup_error = "missing ANTHROPIC_API_KEY"
app._server_startup_missing_credentials_provider = "anthropic"
app._retry_startup_with_model = AsyncMock() # ty: ignore
with patch(
"deepagents_code.model_config.get_provider_auth_status",
return_value=ProviderAuthStatus(
state=ProviderAuthState.MISSING,
provider="anthropic",
),
):
retried = await app._maybe_retry_startup_after_auth_change()
assert retried is False
app._retry_startup_with_model.assert_not_awaited() # ty: ignore
async def test_auth_change_no_retry_without_startup_failure(self) -> None:
"""No credentials-blocked failure means nothing to retry."""
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._server_kwargs = {"model_name": "anthropic:claude-opus-4-7"}
app._server_startup_error = None
app._server_startup_missing_credentials_provider = None
app._retry_startup_with_model = AsyncMock() # ty: ignore
retried = await app._maybe_retry_startup_after_auth_change()
assert retried is False
app._retry_startup_with_model.assert_not_awaited() # ty: ignore
async def test_resume_after_auth_prefers_deferred_start(self) -> None:
"""A deferred first launch wins; the retry path must not also fire."""
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._maybe_start_deferred_server_from_default = AsyncMock( # ty: ignore
return_value=True,
)
app._maybe_retry_startup_after_auth_change = AsyncMock() # ty: ignore
await app._resume_server_after_auth_change()
app._maybe_start_deferred_server_from_default.assert_awaited_once() # ty: ignore
app._maybe_retry_startup_after_auth_change.assert_not_awaited() # ty: ignore
async def test_resume_after_auth_falls_back_to_retry(self) -> None:
"""No deferred launch pending falls through to the retry path."""
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._maybe_start_deferred_server_from_default = AsyncMock( # ty: ignore
return_value=False,
)
app._maybe_retry_startup_after_auth_change = AsyncMock() # ty: ignore
await app._resume_server_after_auth_change()
app._maybe_start_deferred_server_from_default.assert_awaited_once() # ty: ignore
app._maybe_retry_startup_after_auth_change.assert_awaited_once() # ty: ignore
async def test_auth_change_retry_resolves_default_when_name_absent(self) -> None:
"""Missing `model_name` falls back to the configured default spec."""
from deepagents_code.model_config import (
ProviderAuthSource,
ProviderAuthState,
ProviderAuthStatus,
)
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
# No `model_name` and no `model_params` — exercises the default-spec
# fallback and the `extra_kwargs=None` forwarding together.
app._server_kwargs = {}
app._server_startup_error = "missing ANTHROPIC_API_KEY"
app._server_startup_missing_credentials_provider = "anthropic"
app._retry_startup_with_model = AsyncMock() # ty: ignore
with (
patch(
"deepagents_code.model_config.get_provider_auth_status",
return_value=ProviderAuthStatus(
state=ProviderAuthState.CONFIGURED,
provider="anthropic",
source=ProviderAuthSource.STORED,
),
),
patch(
"deepagents_code.config._get_default_model_spec",
return_value="anthropic:claude-opus-4-7",
),
):
retried = await app._maybe_retry_startup_after_auth_change()
assert retried is True
app._retry_startup_with_model.assert_awaited_once_with( # ty: ignore
"anthropic:claude-opus-4-7",
extra_kwargs=None,
)
async def test_auth_change_retry_surfaces_malformed_default_config(self) -> None:
"""A malformed default-model config is surfaced, not silently dropped."""
from deepagents_code.model_config import (
ModelConfigError,
ProviderAuthSource,
ProviderAuthState,
ProviderAuthStatus,
)
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._server_kwargs = {}
app._server_startup_error = "missing ANTHROPIC_API_KEY"
app._server_startup_missing_credentials_provider = "anthropic"
app._retry_startup_with_model = AsyncMock() # ty: ignore
app._mount_message = AsyncMock() # ty: ignore
with (
patch(
"deepagents_code.model_config.get_provider_auth_status",
return_value=ProviderAuthStatus(
state=ProviderAuthState.CONFIGURED,
provider="anthropic",
source=ProviderAuthSource.STORED,
),
),
patch(
"deepagents_code.config._get_default_model_spec",
side_effect=ModelConfigError("unknown provider 'bogus'"),
),
):
retried = await app._maybe_retry_startup_after_auth_change()
assert retried is False
app._retry_startup_with_model.assert_not_awaited() # ty: ignore
app._mount_message.assert_awaited_once() # ty: ignore
mounted = app._mount_message.await_args.args[0] # ty: ignore
assert isinstance(mounted, ErrorMessage)
async def test_auth_change_retry_silent_without_default_credentials(self) -> None:
"""No usable default credentials means a quiet no-op, no error message."""
from deepagents_code.model_config import (
NoCredentialsConfiguredError,
ProviderAuthSource,
ProviderAuthState,
ProviderAuthStatus,
)
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._server_kwargs = {}
app._server_startup_error = "missing ANTHROPIC_API_KEY"
app._server_startup_missing_credentials_provider = "anthropic"
app._retry_startup_with_model = AsyncMock() # ty: ignore
app._mount_message = AsyncMock() # ty: ignore
with (
patch(
"deepagents_code.model_config.get_provider_auth_status",
return_value=ProviderAuthStatus(
state=ProviderAuthState.CONFIGURED,
provider="anthropic",
source=ProviderAuthSource.STORED,
),
),
patch(
"deepagents_code.config._get_default_model_spec",
side_effect=NoCredentialsConfiguredError("no creds"),
),
):
retried = await app._maybe_retry_startup_after_auth_change()
assert retried is False
app._retry_startup_with_model.assert_not_awaited() # ty: ignore
app._mount_message.assert_not_awaited() # ty: ignore
async def test_auth_change_no_retry_without_server_kwargs(self) -> None:
"""A pending failure with no app-owned server kwargs cannot retry."""
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._server_kwargs = None
app._server_startup_error = "missing ANTHROPIC_API_KEY"
app._server_startup_missing_credentials_provider = "anthropic"
app._retry_startup_with_model = AsyncMock() # ty: ignore
retried = await app._maybe_retry_startup_after_auth_change()
assert retried is False
app._retry_startup_with_model.assert_not_awaited() # ty: ignore
async def test_auth_change_no_retry_when_failure_not_credentials(self) -> None:
"""A non-credentials startup failure has no blocking provider to retry."""
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._server_kwargs = {"model_name": "anthropic:claude-opus-4-7"}
app._server_startup_error = "some other startup failure"
app._server_startup_missing_credentials_provider = None
app._retry_startup_with_model = AsyncMock() # ty: ignore
retried = await app._maybe_retry_startup_after_auth_change()
assert retried is False
app._retry_startup_with_model.assert_not_awaited() # ty: ignore
async def test_failing_deferred_action_does_not_block_others(self) -> None:
"""A failing deferred action should not prevent subsequent ones."""
app = DeepAgentsApp()
@@ -59,6 +59,7 @@ class _AuthHostApp(App[None]):
super().__init__()
self.prompt_result: AuthResult | None = None
self.prompt_dismissed = False
self.credential_saved_count = 0
def compose(self) -> ComposeResult:
"""Render a placeholder root."""
@@ -79,6 +80,12 @@ class _AuthHostApp(App[None]):
"""Push the manager screen."""
self.push_screen(AuthManagerScreen())
def on_auth_manager_screen_credential_saved(
self, _event: AuthManagerScreen.CredentialSaved
) -> None:
"""Record credential-save notifications from the manager."""
self.credential_saved_count += 1
class TestCodexAuthScreen:
"""Behavioral tests for ChatGPT sign-in modal helpers."""
@@ -815,6 +822,33 @@ api_key_env = "MY_GATEWAY_API_KEY"
class TestAuthManagerScreen:
"""Behavioral tests for the manager listing."""
async def test_prompt_save_posts_credential_saved_event(self) -> None:
"""Saving a key notifies the app before the manager itself closes."""
app = _AuthHostApp()
async with app.run_test() as pilot:
app.show_manager()
await pilot.pause()
screen = cast("AuthManagerScreen", app.screen)
screen._on_prompt_closed(AuthResult.SAVED)
await pilot.pause()
assert app.credential_saved_count == 1
async def test_prompt_cancel_does_not_post_credential_saved_event(self) -> None:
"""Cancelling or deleting credentials should not trigger startup recovery."""
app = _AuthHostApp()
async with app.run_test() as pilot:
app.show_manager()
await pilot.pause()
screen = cast("AuthManagerScreen", app.screen)
screen._on_prompt_closed(AuthResult.CANCELLED)
screen._on_prompt_closed(AuthResult.DELETED)
await pilot.pause()
assert app.credential_saved_count == 0
async def test_lists_known_providers(self) -> None:
"""Every well-known provider appears in the option list."""
app = _AuthHostApp()