fix(cli): allow TUI launch without preconfigured model credentials (#3301)

The CLI no longer requires a preconfigured model or environment
credentials to open. When no default model can be resolved on startup,
the TUI launches in a deferred state with inline guidance instead of
exiting with an error code.

## Changes

- Launch the TUI even when no model credentials are configured.
`run_textual_cli_async` catches the no-credentials `ModelConfigError`,
sets `defer_server_start=True`, and opens the app rather than printing
an error and exiting.
- Defer server startup inside `DeepAgentsApp` via a new
`defer_server_start` init flag. The UI is fully usable, but the backing
server remains paused until the user runs `/auth`, `/model`, or
`/reload`.
- Auto-resume deferred startup once a model is available.
`_maybe_start_deferred_server_from_default()` checks for a resolved
default model after auth flows and automatically transitions from
deferred to connecting state.
- First-launch guidance. `_mount_deferred_start_notice()` displays a
message pointing users to `/model` when they land in a session with no
credentials and no pre-selected model.
- Updated `_get_default_model_spec` to consider stored TUI credentials
(`auth_store.get_stored_key`) and `get_provider_auth_status()` rather
than only environment variables, making the fallback chain more robust
for deferred-start scenarios.
- Preserve initial prompt (`-m`) submissions across deferred startup.
The prompt is held until `on_deep_agents_app_server_ready` fires instead
of being consumed before the agent exists.
This commit is contained in:
Mason Daugherty
2026-05-10 22:40:20 -04:00
committed by GitHub
parent f826c4c4c1
commit aa28ff7376
6 changed files with 232 additions and 36 deletions
+75 -11
View File
@@ -92,6 +92,11 @@ from deepagents_cli.widgets.welcome import WelcomeBanner
logger = logging.getLogger(__name__)
_monotonic = time.monotonic
_DEFERRED_START_NOTICE = (
"No model is configured yet. Run `/model` to choose one. "
"Deep Agents will ask for credentials for the selected provider."
)
# Serializes process-local read-modify-write operations for `config.toml`.
# Without this, overlapping global-theme and per-terminal-theme saves can each
# read the same pre-mutation state and then clobber the other's keys.
@@ -1054,6 +1059,7 @@ class DeepAgentsApp(App):
server_kwargs: dict[str, Any] | None = None,
mcp_preload_kwargs: dict[str, Any] | None = None,
model_kwargs: dict[str, Any] | None = None,
defer_server_start: bool = False,
title: str | None = None,
sub_title: str | None = None,
**kwargs: Any,
@@ -1106,6 +1112,8 @@ class DeepAgentsApp(App):
When provided, model creation runs in a background worker after
first paint instead of blocking startup.
defer_server_start: Whether to keep CLI-owned server startup paused
until the user configures credentials or explicitly picks a model.
title: Override the Textual `App.title` shown in the optional
header bar.
@@ -1270,12 +1278,21 @@ class DeepAgentsApp(App):
"""Cached kwargs for `start_server_and_get_agent`.
When non-`None`, startup is deferred and the UI begins in
"Connecting..." state.
"Connecting..." state unless `_server_startup_deferred` is set.
Re-used so downstream features that restart the server (e.g. `/agents`)
start from the same config.
"""
self._server_startup_deferred = defer_server_start
"""True when no model can be selected yet, usually first launch with
no credentials. The TUI is usable, but server startup waits for
`/auth`, `/reload`, or `/model`.
"""
self._server_startup_deferred_notice_shown = False
"""Whether the first-launch no-model guidance has been mounted."""
self._mcp_preload_kwargs = mcp_preload_kwargs
"""Kwargs for `_preload_session_mcp_server_info`, run concurrently
with server startup when `server_kwargs` is set."""
@@ -1356,7 +1373,9 @@ class DeepAgentsApp(App):
"""
# Lifecycle flags & re-entry guards
self._connecting = server_kwargs is not None
self._connecting = (
server_kwargs is not None and not self._server_startup_deferred
)
"""True while the backing server is being started or restarted.
Gates message handling so user input is queued until the agent is
@@ -1906,6 +1925,9 @@ class DeepAgentsApp(App):
self._ui_adapter._on_tokens_pending = self._show_pending_tokens
self._ui_adapter._on_tokens_show = self._show_tokens
if self._server_startup_deferred:
await self._mount_deferred_start_notice()
# Fire-and-forget workers — none of these block the event loop.
# Discover skills first so /skill: autocomplete is ready as early
@@ -1919,7 +1941,7 @@ class DeepAgentsApp(App):
self.run_worker(self._init_session_state, exclusive=True, group="session-init")
# Server startup (model creation + server process)
if self._server_kwargs is not None:
if self._server_kwargs is not None and not self._server_startup_deferred:
self.run_worker(
self._start_server_background,
exclusive=True,
@@ -1988,7 +2010,7 @@ class DeepAgentsApp(App):
# `on_deep_agents_app_server_ready` fires; otherwise run it now so the
# non-connecting path (pre-built agent) also honors `--startup-cmd` and
# serializes startup against user input.
if not self._connecting:
if not self._connecting and not self._server_startup_deferred:
self.call_after_refresh(
lambda: asyncio.create_task(self._run_session_start_sequence())
)
@@ -3738,6 +3760,9 @@ class DeepAgentsApp(App):
startup command before any user-facing agent work guarantees the
agent never observes input until the command has completed.
"""
if self._server_startup_deferred:
return
if self._launch_init_requested:
self._ensure_launch_init_task()
launch_init_task = self._launch_init_task
@@ -5048,6 +5073,7 @@ class DeepAgentsApp(App):
skill_lines.append(f" - Removed: {', '.join(removed_skills)}")
report += "\nSkills updated:\n" + "\n".join(skill_lines)
await self._mount_message(AppMessage(report))
await self._maybe_start_deferred_server_from_default()
elif cmd.startswith("/skill:"):
await self._handle_skill_command(command)
# -- Hidden debug commands (not in COMMANDS / autocomplete) -----------
@@ -5479,6 +5505,8 @@ class DeepAgentsApp(App):
self._run_agent_task(message, message_kwargs=message_kwargs),
exclusive=False,
)
elif self._server_startup_deferred:
await self._mount_message(AppMessage(_DEFERRED_START_NOTICE))
elif not self._server_startup_error:
# When a server-startup failure is in flight, the chat
# `ErrorMessage` mounted by `on_deep_agents_app_server_start_failed`
@@ -5487,6 +5515,13 @@ class DeepAgentsApp(App):
AppMessage("Agent not configured for this session.")
)
async def _mount_deferred_start_notice(self) -> None:
"""Tell first-launch users how to configure model credentials."""
if self._server_startup_deferred_notice_shown:
return
self._server_startup_deferred_notice_shown = True
await self._mount_message(AppMessage(_DEFERRED_START_NOTICE))
async def _run_agent_task(
self,
message: str,
@@ -7009,6 +7044,8 @@ class DeepAgentsApp(App):
def handle_result(_result: None) -> None:
if self._chat_input:
self._chat_input.focus_input()
task = asyncio.create_task(self._maybe_start_deferred_server_from_default())
task.add_done_callback(_log_task_exception)
self.push_screen(AuthManagerScreen(), handle_result)
@@ -8223,14 +8260,13 @@ class DeepAgentsApp(App):
timeout=3,
)
return
# Recover from a failed startup (e.g., `MissingCredentialsError`).
# The server never came up, so the only way out without
# restarting the CLI is to retry startup with the new model.
# Only valid for CLI-owned servers.
# Recover from a startup that has not produced a server yet:
# either a deferred first launch with no credentials, or a
# failed startup such as `MissingCredentialsError`.
if (
self._server_startup_error is not None
and self._server_kwargs is not None
):
self._server_startup_deferred
or self._server_startup_error is not None
) and self._server_kwargs is not None:
await self._retry_startup_with_model(
model_spec, extra_kwargs=extra_kwargs
)
@@ -8404,6 +8440,7 @@ class DeepAgentsApp(App):
self._server_startup_error = None
self._server_startup_missing_credentials_provider = None
self._server_startup_deferred = False
self._connecting = True
try:
banner = self.query_one("#welcome-banner", WelcomeBanner)
@@ -8433,6 +8470,29 @@ class DeepAgentsApp(App):
group="server-startup",
)
async def _maybe_start_deferred_server_from_default(self) -> bool:
"""Start a deferred first-launch server once a default model resolves.
Returns:
`True` when startup was kicked off, otherwise `False`.
"""
if not self._server_startup_deferred:
return False
from deepagents_cli.config import _get_default_model_spec
from deepagents_cli.model_config import ModelConfigError
try:
model_spec = _get_default_model_spec()
except ModelConfigError as exc:
if str(exc).startswith("No credentials configured"):
return False
await self._mount_message(ErrorMessage(str(exc)))
return False
await self._retry_startup_with_model(model_spec)
return True
async def _set_default_model(self, model_spec: str) -> None:
"""Set the default model in config without switching the current session.
@@ -8523,6 +8583,7 @@ async def run_textual_app(
server_kwargs: dict[str, Any] | None = None,
mcp_preload_kwargs: dict[str, Any] | None = None,
model_kwargs: dict[str, Any] | None = None,
defer_server_start: bool = False,
title: str | None = None,
sub_title: str | None = None,
) -> AppResult:
@@ -8568,6 +8629,8 @@ async def run_textual_app(
When provided, model creation runs in a background worker after
first paint so the splash screen appears immediately.
defer_server_start: Whether to keep CLI-owned server startup paused
until credentials or a model are configured from inside the TUI.
title: Override the Textual `App.title` shown in the optional header
bar (gated on `DEEPAGENTS_CLI_SHOW_HEADER`). When `None`, the
default `"Deep Agents"` is used.
@@ -8595,6 +8658,7 @@ async def run_textual_app(
server_kwargs=server_kwargs,
mcp_preload_kwargs=mcp_preload_kwargs,
model_kwargs=model_kwargs,
defer_server_start=defer_server_start,
title=title,
sub_title=sub_title,
)
+8 -5
View File
@@ -1852,7 +1852,11 @@ def _get_default_model_spec() -> str:
Raises:
ModelConfigError: If no credentials are configured.
"""
from deepagents_cli.model_config import ModelConfig, ModelConfigError
from deepagents_cli.model_config import (
ModelConfig,
ModelConfigError,
get_provider_auth_status,
)
config = ModelConfig.load()
if config.default_model:
@@ -1861,12 +1865,11 @@ def _get_default_model_spec() -> str:
if config.recent_model:
return config.recent_model
s = _get_settings()
if s.has_openai:
if get_provider_auth_status("openai").as_legacy_bool() is True:
return "openai:gpt-5.5"
if s.has_anthropic:
if get_provider_auth_status("anthropic").as_legacy_bool() is True:
return "anthropic:claude-opus-4-7"
if s.has_google:
if get_provider_auth_status("google_genai").as_legacy_bool() is True:
return "google_genai:gemini-3.1-pro-preview"
msg = (
+29 -16
View File
@@ -1001,29 +1001,41 @@ async def run_textual_cli_async(
# bar can show the model on first paint. The expensive create_model()
# (~560ms) is deferred to a background worker.
defer_server_start = False
try:
resolved_spec = model_name or _get_default_model_spec()
except ModelConfigError as e:
from rich.markup import escape
if not str(e).startswith("No credentials configured"):
from rich.markup import escape
from deepagents_cli.config import console
from deepagents_cli.config import console
console.print(f"[bold red]Error:[/bold red] {escape(str(e))}", highlight=False)
return AppResult(return_code=1, thread_id=None)
console.print(
f"[bold red]Error:[/bold red] {escape(str(e))}", highlight=False
)
return AppResult(return_code=1, thread_id=None)
resolved_spec = ""
defer_server_start = True
parsed = ModelSpec.try_parse(resolved_spec)
if parsed:
settings.model_provider = parsed.provider
settings.model_name = parsed.model
if resolved_spec:
parsed = ModelSpec.try_parse(resolved_spec)
if parsed:
settings.model_provider = parsed.provider
settings.model_name = parsed.model
else:
settings.model_name = resolved_spec
settings.model_provider = detect_provider(resolved_spec) or ""
else:
settings.model_name = resolved_spec
settings.model_provider = detect_provider(resolved_spec) or ""
settings.model_provider = ""
settings.model_name = ""
model_kwargs: dict[str, Any] = {
"model_spec": model_name,
"extra_kwargs": model_params,
"profile_overrides": profile_override,
}
model_kwargs: dict[str, Any] | None = None
if not defer_server_start:
model_kwargs = {
"model_spec": model_name or resolved_spec,
"extra_kwargs": model_params,
"profile_overrides": profile_override,
}
# Build kwargs for deferred server startup (runs inside the TUI).
# Never pass auto_approve to the server — the interactive server must
@@ -1032,7 +1044,7 @@ async def run_textual_cli_async(
# session_state.auto_approve in textual_adapter.py.
server_kwargs: dict[str, Any] = {
"assistant_id": assistant_id,
"model_name": model_name,
"model_name": model_name or resolved_spec or None,
"model_params": model_params,
"sandbox_type": sandbox_type,
"sandbox_id": sandbox_id,
@@ -1068,6 +1080,7 @@ async def run_textual_cli_async(
server_kwargs=server_kwargs,
mcp_preload_kwargs=mcp_preload_kwargs,
model_kwargs=model_kwargs,
defer_server_start=defer_server_start,
)
except Exception as e:
logger.debug("App error", exc_info=True)
+66
View File
@@ -166,6 +166,72 @@ class TestInitialPromptOnMount:
assert submitted == [("code-review", "review this diff", None)]
async def test_deferred_start_preserves_initial_prompt_until_server_ready(
self,
) -> None:
"""No-credentials startup should not consume `-m` before connect."""
app = DeepAgentsApp(
thread_id="new-thread-123",
initial_prompt="hello after auth",
server_kwargs={"assistant_id": "agent", "model_name": None},
defer_server_start=True,
)
submitted: list[str] = []
async def capture(msg: str) -> None: # noqa: RUF029
submitted.append(msg)
app._handle_user_message = capture # type: ignore[assignment]
async with app.run_test() as pilot:
await pilot.pause()
await pilot.pause()
assert submitted == []
assert app._has_initial_submission()
app._server_startup_deferred = False
app.on_deep_agents_app_server_ready(
app.ServerReady(
agent=MagicMock(),
server_proc=None,
mcp_server_info=[],
)
)
for _ in range(3):
await pilot.pause()
assert submitted == ["hello after auth"]
async def test_deferred_start_mounts_auth_guidance(self) -> None:
"""First launch without credentials should show next-step guidance."""
app = DeepAgentsApp(
server_kwargs={"assistant_id": "agent", "model_name": None},
defer_server_start=True,
)
messages: list[AppMessage] = []
async def capture(message: AppMessage) -> None: # noqa: RUF029
messages.append(message)
def fake_run_worker(work: object, *args: object, **kwargs: object) -> MagicMock:
del args, kwargs
if inspect.iscoroutine(work):
work.close()
return MagicMock()
app._mount_message = capture # type: ignore[assignment]
app.run_worker = fake_run_worker # type: ignore[method-assign]
with patch(
"deepagents_cli.update_check.is_update_check_enabled",
return_value=False,
):
await app._post_paint_init()
assert len(messages) == 1
assert "/model" in str(messages[0].content)
assert "credentials" in str(messages[0].content)
class TestStartupSequence:
"""Tests for post-connect startup sequencing."""
+29 -3
View File
@@ -743,17 +743,43 @@ class TestBuildMissingToolNotification:
class TestRunTextualCliAsyncModelConfigError:
"""Verify ModelConfigError is caught cleanly before launching the TUI."""
"""Verify default model config errors are handled before launching the TUI."""
async def test_returns_error_code_on_no_credentials(self) -> None:
"""ModelConfigError from _get_default_model_spec gives return code 1."""
async def test_launches_tui_on_no_credentials(self) -> None:
"""Missing default credentials should be recoverable inside the TUI."""
from deepagents_cli.model_config import ModelConfigError
app_result = AppResult(return_code=0, thread_id="t-1")
captured_kwargs: dict[str, Any] = {}
async def _stub(**kwargs: Any) -> AppResult:
captured_kwargs.update(kwargs)
await asyncio.sleep(0)
return app_result
with (
patch(
"deepagents_cli.config._get_default_model_spec",
side_effect=ModelConfigError("No credentials configured"),
),
patch("deepagents_cli.app.run_textual_app", new=_stub),
):
result = await run_textual_cli_async("agent")
assert result == app_result
assert captured_kwargs["defer_server_start"] is True
assert captured_kwargs["model_kwargs"] is None
assert captured_kwargs["server_kwargs"]["model_name"] is None
async def test_returns_error_code_on_other_model_config_error(self) -> None:
"""Non-recoverable default model errors should still block startup."""
from deepagents_cli.model_config import ModelConfigError
with (
patch(
"deepagents_cli.config._get_default_model_spec",
side_effect=ModelConfigError("Invalid model config"),
),
patch("deepagents_cli.config._get_console") as mock_console_fn,
):
mock_console = MagicMock()
+25 -1
View File
@@ -3858,18 +3858,38 @@ recent = "openai:gpt-5.2"
with (
patch.object(model_config, "DEFAULT_CONFIG_PATH", config_path),
patch("deepagents_cli.auth_store.get_stored_key", return_value=None),
patch.object(settings, "openai_api_key", None),
patch.object(settings, "anthropic_api_key", "test-key"),
patch.dict(
"os.environ",
{"ANTHROPIC_API_KEY": "test-key"},
clear=False,
clear=True,
),
):
result = _get_default_model_spec()
assert result == "anthropic:claude-opus-4-7"
def test_stored_key_used_when_neither_model_set(self, tmp_path):
"""Falls back to stored TUI credentials when no env vars are set."""
from deepagents_cli.config import _get_default_model_spec
config_path = tmp_path / "config.toml"
config_path.write_text("")
def stored_key(provider: str) -> str | None:
return "test-key" if provider == "anthropic" else None
with (
patch.object(model_config, "DEFAULT_CONFIG_PATH", config_path),
patch("deepagents_cli.auth_store.get_stored_key", side_effect=stored_key),
patch.dict("os.environ", {}, clear=True),
):
result = _get_default_model_spec()
assert result == "anthropic:claude-opus-4-7"
def test_vertex_project_does_not_drive_env_default(self, tmp_path):
"""Vertex project alone should not select an automatic default model."""
from deepagents_cli.config import _get_default_model_spec, settings
@@ -3880,6 +3900,8 @@ recent = "openai:gpt-5.2"
with (
patch.object(model_config, "DEFAULT_CONFIG_PATH", config_path),
patch("deepagents_cli.auth_store.get_stored_key", return_value=None),
patch.dict("os.environ", {}, clear=True),
patch.object(settings, "openai_api_key", None),
patch.object(settings, "anthropic_api_key", None),
patch.object(settings, "google_api_key", None),
@@ -3899,6 +3921,8 @@ recent = "openai:gpt-5.2"
with (
patch.object(model_config, "DEFAULT_CONFIG_PATH", config_path),
patch("deepagents_cli.auth_store.get_stored_key", return_value=None),
patch.dict("os.environ", {}, clear=True),
patch.object(settings, "openai_api_key", None),
patch.object(settings, "anthropic_api_key", None),
patch.object(settings, "google_api_key", None),