mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user