feat(code): reload env from /auth modal via Ctrl+R (#4566)

The `/auth` API-key modal now supports `Ctrl+R` to reload credentials
from `.env`/environment without restarting the app.

---

The `/auth` advanced tab tells users they can set a credential env var
instead of pasting a key, but once they do there's no way to make the
running app pick it up without a full restart — the modal becomes a dead
end. This adds a `Ctrl+R` binding to `AuthPromptScreen` that mirrors the
existing `/reload` command: it re-reads `.env`/environment, re-probes
credential status, and — when the provider was blocked and is now
resolvable — dismisses with `SAVED` so the caller retries the original
operation. Otherwise it refreshes the modal in place (preserving any
in-progress input) and toasts the outcome.

A reload can only pick up `.env` files (project and global) and
variables present at launch; a variable `export`ed in a *separate* shell
after startup is invisible to the running process and genuinely needs a
relaunch. That caveat is now surfaced in the Advanced env-var copy and
in the "no credentials detected" toast so users aren't left guessing.

Made by [Open
SWE](https://openswe.vercel.app/agents/114be5ea-e7ae-9927-573f-c68a0331a448)

## References
- Plan:
https://openswe.vercel.app/agents/114be5ea-e7ae-9927-573f-c68a0331a448/plan

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-07-08 20:45:07 -04:00
committed by GitHub
parent 7c5bf542c2
commit f07d6387b7
4 changed files with 449 additions and 58 deletions
+25 -17
View File
@@ -2336,8 +2336,9 @@ class ModelConfig:
Returns:
Parsed `ModelConfig` instance.
Returns empty config if file is missing, unreadable, or contains
invalid TOML syntax.
Returns empty config if file is missing, unreadable, contains
invalid TOML syntax, or is structurally invalid (valid TOML of
the wrong shape, e.g. a scalar `[models]`).
"""
global _default_config_cache # noqa: PLW0603 # Module-level cache requires global statement
is_default = config_path is None
@@ -2356,6 +2357,12 @@ class ModelConfig:
try:
with config_path.open("rb") as f:
data = tomllib.load(f)
models_section = data.get("models", {})
config = cls(
default_model=models_section.get("default"),
recent_model=models_section.get("recent"),
providers=models_section.get("providers", {}),
)
except tomllib.TOMLDecodeError as e:
logger.warning(
"Config file %s has invalid TOML syntax: %s. "
@@ -2363,23 +2370,24 @@ class ModelConfig:
config_path,
e,
)
fallback = cls()
if is_default:
_default_config_cache = fallback
return fallback
config = cls()
except (PermissionError, OSError) as e:
logger.warning("Could not read config file %s: %s", config_path, e)
fallback = cls()
if is_default:
_default_config_cache = fallback
return fallback
models_section = data.get("models", {})
config = cls(
default_model=models_section.get("default"),
recent_model=models_section.get("recent"),
providers=models_section.get("providers", {}),
)
config = cls()
except (AttributeError, TypeError) as e:
# Syntactically valid TOML can still have the wrong shape — a scalar
# `[models]`, a non-table `providers` — which surfaces here as an
# AttributeError from `.get(...)` or a TypeError from the dataclass
# constructor. Treat it like any other unreadable config rather than
# letting it crash callers (e.g. the /auth modal on Ctrl+R) that
# assume load() is total and never raises.
logger.warning(
"Config file %s is structurally invalid: %s. "
"Ignoring config file. Fix the file or delete it to reset.",
config_path,
e,
)
config = cls()
# Validate config consistency
config._validate()
+139 -37
View File
@@ -311,7 +311,8 @@ class AuthResult(StrEnum):
"""
SAVED = "saved"
"""User pasted a key and it was persisted."""
"""A key was persisted, or a reload (Ctrl+R) made the provider's credential
resolvable. Either way the caller should retry the original operation."""
DELETED = "deleted"
"""User cleared the existing stored key. No retry should follow."""
@@ -519,6 +520,7 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "cancel", "Cancel", show=False, priority=True),
Binding("f2", "toggle_advanced", "Advanced", show=False, priority=True),
Binding("ctrl+r", "reload_env", "Reload", show=False, priority=True),
Binding("ctrl+d", "delete_stored", "Delete stored", show=False, priority=True),
]
@@ -654,48 +656,79 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
# project name and an endpoint chosen from a region selector (US/EU SaaS
# or a custom self-hosted URL), and saving a key turns tracing on.
self._is_langsmith = is_langsmith(provider)
# Resolve the current credential source and probe the store, but never
# let a corrupt `auth.json`/config crash the screen at construction
# time — Textual would propagate the exception before the modal mounts.
# Treat unreadable as "no env source" / "no existing key" and surface a
# one-line warning at compose time. The status is consumed only
# cosmetically (title prefix, env note), so a MISSING fallback is safe.
# The config is loaded once here so compose-time helpers can read the
# cached instance instead of reloading outside this crash-safety guard.
# Probe the store once here so compose-time helpers read the cached
# `self._config` instead of each reloading it. See
# `_probe_credential_state` for the crash-safety rationale that lets
# this run at construction (before the modal mounts) without a guard.
self._probe_credential_state()
self._advanced_visible = bool(self._existing_base_url or self._existing_project)
self._refresh_endpoint_env_notice()
def _probe_credential_state(self) -> None:
"""Resolve the current credential source, store, base URL, and project.
Never let an unreadable auth.json crash the screen: Textual would
propagate the exception before the modal mounts (at construction) or
while it is live (on Ctrl+R reload). `auth_store` funnels all store
corruption into RuntimeError, so catch that, treat unreadable store
metadata as "no existing key", and surface a one-line warning at
compose time. The resolved auth status is kept: `get_provider_auth_status`
already falls back to environment credentials when the store can't be
read. (A malformed config.toml can't crash us here — `ModelConfig.load()`
returns an empty config instead of raising.)
"""
try:
self._config = ModelConfig.load()
self._auth_status = _auth_status_for(provider)
self._has_existing = auth_store.get_stored_key(provider) is not None
self._existing_base_url = auth_store.get_stored_base_url(provider) or ""
self._existing_project = auth_store.get_stored_project(provider) or ""
self._advanced_visible = bool(
self._existing_base_url or self._existing_project
self._auth_status = _auth_status_for(self._provider)
except RuntimeError as exc:
logger.warning(
"Could not resolve credentials for %s: %s", self._provider, exc
)
self._config = ModelConfig()
self._auth_status = ProviderAuthStatus(
state=ProviderAuthState.MISSING, provider=self._provider
)
self._set_missing_store_metadata(exc)
return
try:
self._has_existing = auth_store.get_stored_key(self._provider) is not None
self._existing_base_url = (
auth_store.get_stored_base_url(self._provider) or ""
)
self._existing_project = auth_store.get_stored_project(self._provider) or ""
self._region: Region = _region_for_endpoint(self._existing_base_url)
self._store_warning: str | None = None
except RuntimeError as exc:
logger.warning(
"Could not read stored credentials for %s: %s", provider, exc
"Could not read stored credentials for %s: %s", self._provider, exc
)
self._config = ModelConfig()
self._auth_status = ProviderAuthStatus(
state=ProviderAuthState.MISSING, provider=provider
)
self._has_existing = False
self._existing_base_url = ""
self._existing_project = ""
self._advanced_visible = False
self._region = Region.US
self._store_warning = (
f"Credential file is unreadable ({exc}). Saving here will overwrite it."
)
# Surface when an environment endpoint is set: at startup an existing
# `LANGSMITH_ENDPOINT`/`LANGCHAIN_ENDPOINT` takes precedence over the
# stored region, so without this note the radio could show one region
# while traces route somewhere else. (The note fires on presence, not on
# divergence — it may show even when the env value matches the stored
# region.) Saving here applies the selection (the save path replaces the
# env value), so word the note around that.
self._set_missing_store_metadata(exc)
def _set_missing_store_metadata(self, exc: RuntimeError) -> None:
"""Clear optional stored metadata after an auth-store read failure."""
self._has_existing = False
self._existing_base_url = ""
self._existing_project = ""
self._region = Region.US
self._store_warning = (
f"Credential file is unreadable ({exc}). Saving here will overwrite it."
)
def _refresh_endpoint_env_notice(self) -> None:
"""Recompute the LangSmith endpoint-precedence notice from the environment.
Surface when an environment endpoint is set: at startup an existing
`LANGSMITH_ENDPOINT`/`LANGCHAIN_ENDPOINT` takes precedence over the
stored region, so without this note the radio could show one region
while traces route somewhere else. (The note fires on presence, not on
divergence — it may show even when the env value matches the stored
region.) Saving here applies the selection (the save path replaces the
env value), so word the note around that.
Called at construction and again on Ctrl+R reload, since the reload can
add or remove one of those variables and the recompose must reflect it.
"""
self._endpoint_env_notice: str | None = None
if self._is_langsmith and any(
os.environ.get(var) for var in ("LANGSMITH_ENDPOINT", "LANGCHAIN_ENDPOINT")
@@ -850,7 +883,12 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
"priority. Set ",
(self._env_var, TStyle(bold=True)),
" to share a key with other provider SDK tools; it is used "
"only when no scoped or stored key exists. ",
"only when no scoped or stored key exists. After setting one "
"in a .env file, press ",
("Ctrl+R", TStyle(bold=True)),
" to reload without restarting. A variable exported in a "
"separate shell after launch is invisible to this process; "
"it needs a full relaunch. ",
(
"Configuration docs",
self._link_style(CONFIGURATION_DOCS_URL),
@@ -962,7 +1000,11 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
save_label = self._submit_label or (
"Enter replace" if self._has_existing else "Enter save"
)
help_parts = [f"{save_label} {glyphs.bullet} Esc cancel", "F2 advanced"]
help_parts = [
f"{save_label} {glyphs.bullet} Esc cancel",
"F2 advanced",
"Ctrl+R reload",
]
if self._has_existing:
help_parts.append("Ctrl+D delete stored")
yield Static(
@@ -1292,6 +1334,66 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
"""Dismiss without saving."""
self.dismiss(AuthResult.CANCELLED)
async def action_reload_env(self) -> None:
"""Re-read .env/environment, re-probe credentials, and continue.
Runs the same environment/credential reload core as the `/reload`
command (`reload_from_environment` + `clear_caches`), so a credential
env var added to a `.env` file after launch is picked up without a full
restart; it intentionally skips `/reload`'s theme and skill
re-discovery. If the provider was blocked and is now resolvable, dismiss
with SAVED so the caller retries the original operation; otherwise
refresh the modal in place and toast the outcome.
"""
from deepagents_code.config import settings
try:
settings.reload_from_environment()
clear_caches()
except (OSError, ValueError) as exc:
logger.warning("Failed to reload configuration from auth prompt: %s", exc)
self.app.notify(
"Could not reload configuration. Check your .env file and "
"environment variables for syntax errors, then try again.",
severity="error",
markup=False,
)
return
was_blocking = self._auth_status.blocks_start
self._probe_credential_state()
if was_blocking and not self._auth_status.blocks_start:
self.app.notify(
f"Credentials detected for {self._provider}. Continuing.",
markup=False,
)
self.dismiss(AuthResult.SAVED)
return
# The reload may have added/removed a LangSmith endpoint env var; refresh
# the precedence notice so the recompose below doesn't render a stale one.
self._refresh_endpoint_env_notice()
# Preserve any in-progress input across the recompose so a reload never
# discards values the user already started typing.
before = {inp.id: inp.value for inp in self.query(Input)}
await self.recompose()
for inp in self.query(Input):
if inp.id in before:
inp.value = before[inp.id]
self.query_one("#auth-prompt-input", Input).focus()
if was_blocking:
self.app.notify(
"No credentials detected. Set a key in a .env file, then press "
"Ctrl+R. A variable exported in a separate shell needs a full "
"relaunch to take effect.",
markup=False,
)
else:
self.app.notify("Environment reloaded.", markup=False)
def action_delete_stored(self) -> None:
"""Open the delete-confirmation overlay, or quit when nothing is stored.
@@ -1474,6 +1474,40 @@ class TestModelConfigLoad:
assert config.default_model is None
assert config.providers == {}
def test_returns_empty_config_when_models_section_is_not_a_table(
self, tmp_path, caplog
):
"""Valid TOML with a scalar `models` falls back instead of raising.
`load()` must be total: a structurally wrong config surfaces as an
AttributeError from `.get(...)` after a clean parse, which callers like
the /auth modal do not guard against.
"""
config_path = tmp_path / "config.toml"
config_path.write_text('models = "oops"\n')
with caplog.at_level(logging.WARNING, logger="deepagents_code.model_config"):
config = ModelConfig.load(config_path)
assert config.default_model is None
assert config.providers == {}
assert any("structurally invalid" in r.getMessage() for r in caplog.records)
def test_returns_empty_config_when_providers_is_not_a_table(self, tmp_path, caplog):
"""Valid TOML with a non-table `providers` falls back instead of raising.
This shape raises a TypeError from the dataclass constructor
(`MappingProxyType(5)`), the other post-parse failure mode.
"""
config_path = tmp_path / "config.toml"
config_path.write_text("[models]\nproviders = 5\n")
with caplog.at_level(logging.WARNING, logger="deepagents_code.model_config"):
config = ModelConfig.load(config_path)
assert config.providers == {}
assert any("structurally invalid" in r.getMessage() for r in caplog.records)
def test_loads_default_model(self, tmp_path):
"""Loads default model from config."""
config_path = tmp_path / "config.toml"
@@ -7,6 +7,7 @@ from datetime import UTC
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast
import anyio
import pytest
from textual.app import App, ComposeResult
from textual.containers import Container
@@ -1150,6 +1151,251 @@ api_key_url = "javascript:alert(1)"
assert app.prompt_result is AuthResult.CANCELLED
assert auth_store.get_stored_key("openai") is None
async def test_ctrl_r_reload_continues_when_env_now_resolves(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ctrl+R dismisses with SAVED (and clears caches) once creds resolve."""
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("DEEPAGENTS_CODE_ANTHROPIC_API_KEY", raising=False)
notices: list[tuple[str, str | None]] = []
def _capture_notify(
message: str, *_a: object, severity: str | None = None, **_k: object
) -> None:
notices.append((str(message), severity))
# Spy on cache invalidation: get_provider_auth_status reads os.environ
# directly, so a missing clear_caches would still let this test pass on
# the dismiss outcome while silently breaking downstream model lookups.
cache_clears = 0
real_clear = model_config.clear_caches
def _spy_clear() -> None:
nonlocal cache_clears
cache_clears += 1
real_clear()
monkeypatch.setattr("deepagents_code.tui.widgets.auth.clear_caches", _spy_clear)
app = _AuthHostApp()
async with app.run_test() as pilot:
monkeypatch.setattr(app, "notify", _capture_notify)
app.show_prompt("anthropic", "ANTHROPIC_API_KEY")
await pilot.pause()
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-from-env")
await pilot.press("ctrl+r")
await pilot.pause()
assert app.prompt_dismissed is True
assert app.prompt_result is AuthResult.SAVED
assert cache_clears >= 1
assert any("Continuing" in msg for msg, _ in notices)
async def test_ctrl_r_reload_continues_when_store_is_corrupt_but_env_resolves(
self, fake_state_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A corrupt store must not hide a newly resolvable env credential."""
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("DEEPAGENTS_CODE_ANTHROPIC_API_KEY", raising=False)
store_dir = anyio.Path(fake_state_dir)
await store_dir.mkdir(parents=True, exist_ok=True)
await (store_dir / "auth.json").write_text("{", encoding="utf-8")
app = _AuthHostApp()
async with app.run_test() as pilot:
app.show_prompt("anthropic", "ANTHROPIC_API_KEY")
await pilot.pause()
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-env-key")
await pilot.press("ctrl+r")
await pilot.pause()
assert app.prompt_dismissed is True
assert app.prompt_result is AuthResult.SAVED
async def test_ctrl_r_reload_stays_open_when_still_missing(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ctrl+R with nothing newly set keeps the modal open (no false SAVED)."""
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("DEEPAGENTS_CODE_ANTHROPIC_API_KEY", raising=False)
notices: list[tuple[str, str | None]] = []
def _capture_notify(
message: str, *_a: object, severity: str | None = None, **_k: object
) -> None:
notices.append((str(message), severity))
app = _AuthHostApp()
async with app.run_test() as pilot:
monkeypatch.setattr(app, "notify", _capture_notify)
app.show_prompt("anthropic", "ANTHROPIC_API_KEY")
await pilot.pause()
await pilot.press("ctrl+r")
await pilot.pause()
assert app.prompt_dismissed is False
assert isinstance(app.screen, AuthPromptScreen)
assert any("No credentials detected" in msg for msg, _ in notices)
async def test_ctrl_r_reload_when_not_blocking_stays_open_and_toasts(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ctrl+R on a non-blocking modal never false-dismisses with SAVED.
A stored key means the modal opened in a replace/edit flow (not
blocking start), so reload must refresh in place and toast "Environment
reloaded." rather than hand control back as if a fresh key resolved.
"""
auth_store.set_stored_key("anthropic", "sk-ant-stored")
notices: list[tuple[str, str | None]] = []
def _capture_notify(
message: str, *_a: object, severity: str | None = None, **_k: object
) -> None:
notices.append((str(message), severity))
app = _AuthHostApp()
async with app.run_test() as pilot:
monkeypatch.setattr(app, "notify", _capture_notify)
app.show_prompt("anthropic", "ANTHROPIC_API_KEY")
await pilot.pause()
await pilot.press("ctrl+r")
await pilot.pause()
assert app.prompt_dismissed is False
assert isinstance(app.screen, AuthPromptScreen)
assert any(msg == "Environment reloaded." for msg, _ in notices)
async def test_ctrl_r_reload_preserves_typed_key(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An in-progress key survives the reload recompose."""
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("DEEPAGENTS_CODE_ANTHROPIC_API_KEY", raising=False)
app = _AuthHostApp()
async with app.run_test() as pilot:
app.show_prompt("anthropic", "ANTHROPIC_API_KEY")
await pilot.pause()
app.screen.query_one("#auth-prompt-input", Input).value = "half-typed"
await pilot.press("ctrl+r")
await pilot.pause()
value = app.screen.query_one("#auth-prompt-input", Input).value
assert value == "half-typed"
async def test_ctrl_r_reload_surfaces_config_errors(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A malformed reload is caught, toasted as an error, and never crashes."""
notices: list[tuple[str, str | None]] = []
def _capture_notify(
message: str, *_a: object, severity: str | None = None, **_k: object
) -> None:
notices.append((str(message), severity))
app = _AuthHostApp()
async with app.run_test() as pilot:
monkeypatch.setattr(app, "notify", _capture_notify)
app.show_prompt("anthropic", "ANTHROPIC_API_KEY")
await pilot.pause()
def _boom() -> list[str]:
msg = "bad .env"
raise ValueError(msg)
monkeypatch.setattr(
"deepagents_code.config.settings.reload_from_environment", _boom
)
await pilot.press("ctrl+r")
await pilot.pause()
assert app.prompt_dismissed is False
assert isinstance(app.screen, AuthPromptScreen)
# The failure must reach the user as an error toast, not just a log line.
assert any(
"Could not reload configuration" in msg and severity == "error"
for msg, severity in notices
)
async def test_ctrl_r_reload_survives_structurally_invalid_config(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A config.toml that parses but is structurally wrong must not crash.
`ModelConfig.load()` swallows TOML *syntax* errors but lets a clean
parse with the wrong shape (here a scalar `models`) raise
AttributeError from the subsequent `.get(...)`. Since Ctrl+R clears
caches and re-parses live, the probe must catch it at both construction
and reload rather than take down the modal.
"""
config_path = tmp_path / "config.toml"
config_path.write_text('models = "oops"\n')
monkeypatch.setattr(model_config, "DEFAULT_CONFIG_PATH", config_path)
model_config.clear_caches()
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("DEEPAGENTS_CODE_ANTHROPIC_API_KEY", raising=False)
app = _AuthHostApp()
async with app.run_test() as pilot:
# Construction path: show_prompt instantiates the screen, which
# probes credentials synchronously — a raise here would surface as a
# test error, not a caught fallback.
app.show_prompt("anthropic", "ANTHROPIC_API_KEY")
await pilot.pause()
assert isinstance(app.screen, AuthPromptScreen)
# Live reload path: re-parses the bad config after clearing caches.
await pilot.press("ctrl+r")
await pilot.pause()
assert app.prompt_dismissed is False
assert isinstance(app.screen, AuthPromptScreen)
async def test_ctrl_r_reload_refreshes_langsmith_endpoint_notice(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A LangSmith endpoint set via reload surfaces its precedence notice.
The notice is derived in `__init__` and must be recomputed on reload;
otherwise the recompose would render the construction-time (absent)
notice while the region radio reflects the new endpoint.
"""
for var in (
"LANGSMITH_API_KEY",
"DEEPAGENTS_CODE_LANGSMITH_API_KEY",
"LANGSMITH_ENDPOINT",
"LANGCHAIN_ENDPOINT",
):
monkeypatch.delenv(var, raising=False)
app = _AuthHostApp()
async with app.run_test() as pilot:
app.show_prompt("langsmith", "LANGSMITH_API_KEY")
await pilot.pause()
assert not app.screen.query("#auth-prompt-endpoint-env-notice")
monkeypatch.setenv(
"LANGSMITH_ENDPOINT", "https://eu.api.smith.langchain.com"
)
await pilot.press("ctrl+r")
await pilot.pause()
# Still blocking (no key), so the modal recomposed in place — and the
# notice now reflects the endpoint the reload picked up.
assert app.screen.query("#auth-prompt-endpoint-env-notice")
async def test_help_line_advertises_reload(self) -> None:
"""The modal help line tells users Ctrl+R reloads the environment."""
app = _AuthHostApp()
async with app.run_test() as pilot:
app.show_prompt("anthropic", "ANTHROPIC_API_KEY")
await pilot.pause()
help_line = app.screen.query_one(".auth-prompt-help", Static)
content = str(help_line.content)
assert "Ctrl+R reload" in content
async def test_advanced_copy_flags_reload_and_relaunch_caveat(self) -> None:
"""Advanced env-var copy points at Ctrl+R and the separate-shell caveat."""
app = _AuthHostApp()
async with app.run_test() as pilot:
app.show_prompt("anthropic", "ANTHROPIC_API_KEY")
await pilot.pause()
key_meta = str(
app.screen.query_one("#auth-prompt-key-meta", Static).content
)
assert "Ctrl+R" in key_meta
assert "relaunch" in key_meta
async def test_ctrl_d_opens_confirm_then_deletes(self) -> None:
"""Ctrl+D opens the confirmation modal; Enter completes the delete."""
from deepagents_code.tui.widgets.auth import DeleteCredentialConfirmScreen
@@ -1224,9 +1470,11 @@ api_key_url = "javascript:alert(1)"
assert "stored" not in str(title.content)
async def test_init_does_not_crash_on_corrupt_store(
self, fake_state_dir: Path
self, fake_state_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A corrupt auth.json must not crash the prompt at construction."""
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("DEEPAGENTS_CODE_ANTHROPIC_API_KEY", raising=False)
path = fake_state_dir / "auth.json"
path.parent.mkdir(parents=True)
path.write_text("{not json")
@@ -1240,9 +1488,8 @@ api_key_url = "javascript:alert(1)"
error_widgets = app.screen.query(".auth-prompt-error")
warning_text = " ".join(str(w.render()) for w in error_widgets)
assert "unreadable" in warning_text
# The MISSING fallback renders no env-source block and an unprefixed
# title (no warning/checkmark glyph), confirming the cosmetic-only
# degradation the construction guard promises.
# With no environment credential, the missing status renders no
# env-source block and an unprefixed title.
assert not app.screen.query("#auth-prompt-env-status")
title_text = str(app.screen.query_one(".auth-prompt-title", Static).content)
glyphs = get_glyphs()