mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
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:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user