feat(code): collect Tavily key during onboarding (#4233)

Onboarding now offers to store a Tavily web-search API key, and no
longer shows the "web search disabled" toast during first-run setup.

---

Adds an optional Tavily web-search key step to the first-run onboarding
flow, mirroring the LangSmith `/auth` credential path (PR #4193): the
key is persisted via `auth_store` and bridged onto the env var, taking
full effect on next launch. The step is skipped when a Tavily key is
already configured (env or stored). Also suppresses missing-dependency
toasts (e.g. "Web search disabled") while onboarding runs, since the
flow now handles integrations itself — the notices stay reachable via
the notification center (ctrl+n).

Made by [Open
SWE](https://openswe.vercel.app/agents/36c09eb1-5a3d-e633-b8c7-7475b9557a8d)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-06-24 20:11:54 -04:00
committed by GitHub
parent 5e152ac025
commit e321cba570
5 changed files with 500 additions and 23 deletions
+77 -1
View File
@@ -1749,6 +1749,22 @@ class DeepAgentsApp(App):
self._launch_init_requested = launch_init
"""Whether startup should show onboarding during the initial paint."""
self._onboarding_session = launch_init
"""Whether onboarding runs this session (constant for the session).
Unlike `_launch_init_requested`, which is cleared once the flow starts,
this stays set so background workers (e.g. the optional-tools check)
can register missing-dependency notices silently instead of toasting
over the onboarding modals.
Intentionally never reset, including on onboarding completion: the
optional-tools check is scheduled once at startup and may not have run
by the time the flow finishes, so clearing this would let that check
toast the very "Web search disabled" notice onboarding means to defer.
Leaving it set for the whole session is harmless because the check runs
exactly once.
"""
self._launch_init_running = False
"""Re-entry guard for launch init modals."""
@@ -2909,7 +2925,13 @@ class DeepAgentsApp(App):
# update (already notified within CACHE_TTL) does not open the
# modal, so toasts must still fire or returning users never
# see the warning.
suppress_toasts = self._update_modal_pending.is_set()
# Onboarding suppresses too: the flow covers integrations (and
# prompts for a Tavily key) itself, so a "Web search disabled"
# toast over the onboarding modals is noise. Entries stay
# reachable via ctrl+n.
suppress_toasts = (
self._update_modal_pending.is_set() or self._onboarding_session
)
for tool in missing:
notification = build_missing_tool_notification(tool)
@@ -6110,6 +6132,7 @@ class DeepAgentsApp(App):
return
model_spec, provider = result
await self._prompt_launch_tavily()
if self._connecting:
# Bound the wait so a stuck server never traps onboarding.
# Server startup typically completes in seconds; a minute is
@@ -6194,6 +6217,59 @@ class DeepAgentsApp(App):
return
await self._switch_model(model_spec, announce_unchanged=False)
async def _prompt_launch_tavily(self) -> None:
"""Optionally collect and store a Tavily web-search key during onboarding.
Skipped when a Tavily key is already configured (env or stored). A
blank submission or Escape stores nothing; a non-empty key is persisted
via the same `auth_store` path `/auth` uses. The key is also exported to
the process environment (`apply_stored_service_credentials`) so a server
respawn this session picks it up; the already-running server keeps its
spawn-time tools, so web search takes full effect on the next launch (or
after a restart).
"""
from deepagents_code.config import settings
if settings.has_tavily:
return
from deepagents_code.widgets.auth import AuthPromptScreen, AuthResult
result = await self._push_screen_wait(
AuthPromptScreen(
"tavily",
"TAVILY_API_KEY",
reason=(
"Web search is optional but strongly recommended to enhance "
"your agent's capabilities."
),
allow_empty_submit=True,
input_placeholder="Tavily API key (optional)",
submit_label="Enter save/skip",
)
)
if result is not AuthResult.SAVED:
return
from deepagents_code.model_config import apply_stored_service_credentials
apply_stored_service_credentials()
# `apply_stored_service_credentials` is best-effort: it swallows a
# corrupt-store read with only a `logger.warning`, which is invisible
# inside a Textual session. The user just saw the key accepted, so
# confirm it actually reached the environment; if not, say so rather
# than letting web search silently stay disabled. Reaching this branch
# means `has_tavily` was False at bootstrap, so a populated
# `TAVILY_API_KEY` here can only come from the export above.
if not os.environ.get("TAVILY_API_KEY"):
self.notify(
"Saved your Tavily key, but couldn't activate it this "
"session. Restart Deep Agents Code, or re-add it with /auth.",
severity="warning",
markup=False,
)
async def _finish_launch_init(self, *, name: str | None) -> None:
"""Persist onboarding completion and, when given, mount the welcome.
+44 -14
View File
@@ -487,6 +487,9 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
env_var: str | None,
*,
reason: str | None = None,
allow_empty_submit: bool = False,
input_placeholder: str | None = None,
submit_label: str | None = None,
) -> None:
"""Initialize the prompt for `provider`.
@@ -495,13 +498,20 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
env_var: Canonical env var the SDK reads, shown as helper text.
May be `None` for providers that don't use one of the
hardcoded env-var bindings (rare; the prompt still works).
reason: Optional one-line context, e.g.,
`"Required to use anthropic:claude-opus-4-7"`.
reason: Optional context, e.g.,
`"Required to use anthropic:claude-opus-4-8"`.
allow_empty_submit: Whether pressing Enter on an empty key dismisses
with `AuthResult.CANCELLED` instead of showing a validation error.
input_placeholder: Optional placeholder override for the key input.
submit_label: Optional help-label override for the Enter action.
"""
super().__init__()
self._provider = provider
self._env_var = env_var
self._reason = reason
self._allow_empty_submit = allow_empty_submit
self._input_placeholder = input_placeholder
self._submit_label = submit_label
# LangSmith is configured as a tracing service: it has no base-URL
# override but does carry an optional project name, and saving a key
# turns tracing on.
@@ -636,7 +646,8 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
classes="auth-prompt-error",
)
yield Input(
placeholder=(
placeholder=self._input_placeholder
or (
"Paste a new key to replace the stored one"
if self._has_existing
else "Paste your API key"
@@ -644,23 +655,30 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
password=True,
id="auth-prompt-input",
)
storage_note: Content | None
if self._is_langsmith:
storage_note = Content.from_markup(
"Deep Agents Code stores the above key locally and turns on "
"LangSmith tracing. To pause tracing without removing the key, "
"set [bold]DEEPAGENTS_CODE_LANGSMITH_TRACING=false[/bold]."
)
elif is_service(self._provider):
# Services (e.g. Tavily) skip the storage note: the title and
# reason copy already say what the key is for, so it only adds
# redundant copy here.
storage_note = None
else:
storage_note = Content.from_markup(
"Deep Agents Code stores the above key locally and uses it "
"when you select [bold]$provider[/bold] models.",
provider=provider_label,
)
yield Static(
storage_note,
classes="auth-prompt-meta",
id="auth-prompt-storage-note",
)
if storage_note is not None:
yield Static(
storage_note,
classes="auth-prompt-meta",
id="auth-prompt-storage-note",
)
yield Static(
self._build_advanced_toggle_label(),
classes="auth-prompt-advanced-toggle",
@@ -736,7 +754,9 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
base_url_hint_widget.display = self._advanced_visible
yield base_url_hint_widget
yield Static("", classes="auth-prompt-error", id="auth-prompt-error")
save_label = "Enter replace" if self._has_existing else "Enter save"
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"]
if self._has_existing:
help_parts.append("Ctrl+D delete stored")
@@ -780,12 +800,12 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
url = configured_url or PROVIDER_API_KEY_URLS.get(
self._provider, _PROVIDERS_DOCS_URL
)
label = (
"Provider key page"
if configured_url or self._provider in PROVIDER_API_KEY_URLS
else "Provider setup docs"
)
provider = _provider_display_name(self._provider, config)
label = (
f"{provider} key page"
if configured_url or self._provider in PROVIDER_API_KEY_URLS
else f"{provider} setup docs"
)
if self._provider == "azure_openai":
instructions = Content.assemble(
"Find your key in your Azure OpenAI resource's "
@@ -951,6 +971,16 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
base_url = self.query_one("#auth-prompt-base-url", Input).value.strip()
project = ""
if not cleaned:
if self._allow_empty_submit:
# Optional prompts (e.g. the Tavily onboarding step) treat an
# empty submit as an intentional skip. We deliberately reuse
# `CANCELLED` rather than add a `SKIPPED` outcome: every caller
# that allows empty submit wants identical "did not save"
# handling for skip and Escape, so the distinction would be
# dead weight. Revisit if a caller ever needs to tell a
# deliberate decline from an accidental dismissal.
self.dismiss(AuthResult.CANCELLED)
return
self._show_error("API key cannot be empty.")
return
try:
+18
View File
@@ -91,6 +91,24 @@ def _clear_langsmith_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(key, raising=False)
@pytest.fixture(autouse=True)
def _clear_tavily_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Prevent a Tavily key loaded from .env from leaking into tests.
Like `LANGSMITH_*`, `dotenv.load_dotenv()` at `deepagents_code.config`
import time may inject `TAVILY_API_KEY` from a developer's local `.env`.
A leaked key flips `settings.has_tavily` to `True`, which silently changes
onboarding behavior: the launch sequence short-circuits the Tavily step on
a dev machine but runs it on CI, so a test that reaches the step passes
locally yet hangs (real screen push) or writes a credential on CI.
Each test that *needs* a Tavily key should set it explicitly via
`monkeypatch.setenv` or patch `settings.has_tavily`.
"""
for key in ("TAVILY_API_KEY", "DEEPAGENTS_CODE_TAVILY_API_KEY"):
monkeypatch.delenv(key, raising=False)
@pytest.fixture(autouse=True)
def _clear_provider_base_url_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Prevent provider base-URL env vars from leaking into tests.
+234
View File
@@ -904,6 +904,10 @@ class TestStartupSequence:
app._switch_model = switch_model_mock # ty: ignore
app._mount_message = track_mount_message # ty: ignore
app._dispatch_launch_name_hook = MagicMock() # ty: ignore
# The Tavily step has dedicated coverage; stub it here so this
# name/model orchestration test stays isolated from the credential
# store (and the real modal push) regardless of the ambient env.
app._prompt_launch_tavily = AsyncMock() # ty: ignore
with (
patch(
@@ -995,6 +999,10 @@ class TestStartupSequence:
app._write_launch_name_memory = AsyncMock() # ty: ignore
switch_or_install = AsyncMock()
app._switch_or_install_launch_model = switch_or_install # ty: ignore
# Stub the Tavily step (covered separately): without a run_test
# harness the real `_push_screen_wait` would block forever, and on
# CI (no TAVILY_API_KEY) `has_tavily` is False so the step would run.
app._prompt_launch_tavily = AsyncMock() # ty: ignore
# The fallback prompt must NOT run when a result is injected.
prompt_flow_mock = AsyncMock()
app._prompt_launch_dependencies_then_model = prompt_flow_mock # ty: ignore
@@ -1020,6 +1028,50 @@ class TestStartupSequence:
switch_or_install.assert_awaited_once_with("openai:gpt-5.4", "openai")
mark_complete.assert_called_once_with()
async def test_launch_init_sequence_runs_tavily_step_before_model_switch(
self,
) -> None:
"""The sequence must invoke the Tavily step after model resolution.
Regression guard for the wiring itself. The dedicated
`_prompt_launch_tavily` tests exercise the method in isolation, so
without this test the call site could be deleted and the onboarding
Tavily prompt silently lost with every other test still green. The
step must also land before the model switch (its env export feeds a
potential server respawn).
"""
app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
app._mount_message = AsyncMock() # ty: ignore
app._write_launch_name_memory = AsyncMock() # ty: ignore
order: list[str] = []
app._prompt_launch_tavily = AsyncMock( # ty: ignore
side_effect=lambda: order.append("tavily")
)
app._switch_or_install_launch_model = AsyncMock( # ty: ignore
side_effect=lambda *_args: order.append("switch")
)
loop = asyncio.get_running_loop()
name_result: asyncio.Future[str | None] = loop.create_future()
name_result.set_result("Ada")
dependency_result: asyncio.Future[tuple[bool, tuple[str, str] | None]] = (
loop.create_future()
)
dependency_result.set_result((True, ("openai:gpt-5.4", "openai")))
with patch(
"deepagents_code.onboarding.mark_onboarding_complete",
return_value=True,
):
await app._run_launch_init_sequence(
name_result=name_result,
dependency_result=dependency_result,
)
app._prompt_launch_tavily.assert_awaited_once_with() # ty: ignore
assert order == ["tavily", "switch"]
async def test_launch_init_wires_name_screen_to_dependency_screen(self) -> None:
"""On mount, submitting the name switches straight into the deps screen.
@@ -1101,6 +1153,8 @@ class TestStartupSequence:
app._prompt_launch_dependencies_then_model = prompt_flow_mock # ty: ignore
app._switch_model = switch_model_mock # ty: ignore
app._mount_message = mount_message_mock # ty: ignore
# Tavily step covered separately; stub for isolation.
app._prompt_launch_tavily = AsyncMock() # ty: ignore
with (
patch(
@@ -1123,6 +1177,135 @@ class TestStartupSequence:
)
mark_complete.assert_called_once_with()
async def test_prompt_launch_tavily_uses_auth_prompt_and_applies_key(self) -> None:
"""Onboarding should reuse the `/auth` prompt for Tavily credentials."""
from deepagents_code.widgets.auth import AuthPromptScreen, AuthResult
app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
pushed: list[AuthPromptScreen] = []
def capture_prompt(screen: object) -> AuthResult:
assert isinstance(screen, AuthPromptScreen)
pushed.append(screen)
return AuthResult.SAVED
app._push_screen_wait = AsyncMock(side_effect=capture_prompt) # ty: ignore
with (
patch("deepagents_code.config.settings", SimpleNamespace(has_tavily=False)),
patch(
"deepagents_code.model_config.apply_stored_service_credentials"
) as apply_credentials,
):
await app._prompt_launch_tavily()
prompt = pushed[0]
assert prompt._provider == "tavily"
assert prompt._env_var == "TAVILY_API_KEY"
assert prompt._allow_empty_submit is True
assert prompt._input_placeholder == "Tavily API key (optional)"
assert prompt._submit_label == "Enter save/skip"
assert "Web search is optional" in (prompt._reason or "")
apply_credentials.assert_called_once_with()
async def test_prompt_launch_tavily_cancel_does_not_apply_key(self) -> None:
"""A `CANCELLED` result applies no Tavily credential.
At this boundary `_prompt_launch_tavily` only sees the `AuthResult`;
both a blank submit and an Escape produce `CANCELLED`, so they are
indistinguishable here. The distinct gestures are exercised at the
widget layer (`test_optional_empty_submit_cancels_without_error` and
`test_escape_cancels` in `test_auth_widgets.py`).
"""
from deepagents_code.widgets.auth import AuthResult
app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
app._push_screen_wait = AsyncMock(return_value=AuthResult.CANCELLED) # ty: ignore
with (
patch("deepagents_code.config.settings", SimpleNamespace(has_tavily=False)),
patch(
"deepagents_code.model_config.apply_stored_service_credentials"
) as apply_credentials,
):
await app._prompt_launch_tavily()
apply_credentials.assert_not_called()
async def test_prompt_launch_tavily_skips_when_configured(self) -> None:
"""Onboarding should not prompt when a Tavily key already exists."""
app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
push_screen_wait = AsyncMock(return_value="tvly-key")
app._push_screen_wait = push_screen_wait # ty: ignore
with (
patch("deepagents_code.config.settings", SimpleNamespace(has_tavily=True)),
patch("deepagents_code.auth_store.set_stored_key") as set_stored_key,
):
await app._prompt_launch_tavily()
push_screen_wait.assert_not_awaited()
set_stored_key.assert_not_called()
async def test_prompt_launch_tavily_clean_save_activates_key(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A clean save that lands the key in the env shows no toast."""
from deepagents_code.widgets.auth import AuthResult
app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
app._push_screen_wait = AsyncMock(return_value=AuthResult.SAVED) # ty: ignore
notify_mock = MagicMock()
app.notify = notify_mock # ty: ignore
def export_key() -> None:
# Model the real export: the saved key reaches the canonical env
# var the SDK reads.
monkeypatch.setenv("TAVILY_API_KEY", "tvly-real-key")
with (
patch("deepagents_code.config.settings", SimpleNamespace(has_tavily=False)),
patch(
"deepagents_code.model_config.apply_stored_service_credentials",
side_effect=export_key,
) as apply_credentials,
):
await app._prompt_launch_tavily()
apply_credentials.assert_called_once_with()
assert os.environ["TAVILY_API_KEY"] == "tvly-real-key"
notify_mock.assert_not_called()
async def test_prompt_launch_tavily_warns_when_activation_fails(self) -> None:
"""A saved key that never reaches the env warns instead of failing silently.
`apply_stored_service_credentials` is best-effort and only logs on a
corrupt store, which is invisible inside Textual. Onboarding must tell
the user their accepted key didn't take effect this session.
"""
from deepagents_code.widgets.auth import AuthResult
app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
app._push_screen_wait = AsyncMock(return_value=AuthResult.SAVED) # ty: ignore
notify_mock = MagicMock()
app.notify = notify_mock # ty: ignore
with (
patch("deepagents_code.config.settings", SimpleNamespace(has_tavily=False)),
# No side_effect: the export is a no-op, so `TAVILY_API_KEY` stays
# unset (the autouse `_clear_tavily_env` fixture cleared it).
patch(
"deepagents_code.model_config.apply_stored_service_credentials"
) as apply_credentials,
):
await app._prompt_launch_tavily()
apply_credentials.assert_called_once_with()
notify_mock.assert_called_once()
message = str(notify_mock.call_args.args[0])
assert "/auth" in message
assert notify_mock.call_args.kwargs.get("severity") == "warning"
async def test_launch_init_name_memory_does_not_delay_model_prompt(self) -> None:
"""Writing the optional name should not hold the dependency/model transition."""
app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
@@ -1260,6 +1443,9 @@ class TestStartupSequence:
)
switch_failure = RuntimeError("missing credentials")
app._switch_model = AsyncMock(side_effect=switch_failure) # ty: ignore
# Tavily step covered separately; stub so its toasts can't be
# mistaken for the switch-failure toast this test asserts on.
app._prompt_launch_tavily = AsyncMock() # ty: ignore
app._mount_message = AsyncMock() # ty: ignore
app._dispatch_launch_name_hook = MagicMock() # ty: ignore
notify_mock = MagicMock()
@@ -1376,6 +1562,9 @@ class TestStartupSequence:
app._mount_message = AsyncMock() # ty: ignore
app._connecting = True
app._dispatch_launch_name_hook = MagicMock() # ty: ignore
# Tavily step (before the connection wait) is covered separately;
# stub so its toasts can't be mistaken for the timeout toast.
app._prompt_launch_tavily = AsyncMock() # ty: ignore
# Constructor pre-sets the readiness event when no server is configured;
# clear it so the wait_for actually has to time out.
app._connection_ready_event.clear()
@@ -11506,6 +11695,51 @@ class TestNotificationCenterIntegration:
assert entry is not None
assert app._notice_registry.toast_identity_for("dep:ripgrep") is not None
async def test_tool_toasts_suppressed_during_onboarding(self) -> None:
"""During onboarding, missing-dep toasts are silent but still recorded.
The onboarding flow handles integrations itself (and prompts for a
Tavily key), so a "Web search disabled" toast stacked over the
onboarding modals is noise. The entry is still added to the registry
so ctrl+n surfaces it; only the toast is skipped. Suppression here is
keyed on `_onboarding_session` alone, not `_update_modal_pending`.
"""
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
app._onboarding_session = True
# Crucially, _update_modal_pending stays clear so this asserts the
# onboarding clause of `suppress_toasts`, not the update-modal one.
with (
patch(
"deepagents_code.main.check_optional_tools",
return_value=["ripgrep"],
),
patch(
"deepagents_code.main._should_ensure_managed_ripgrep",
return_value=True,
),
patch(
"deepagents_code.main._ripgrep_install_hint",
return_value="brew install ripgrep",
),
patch(
"deepagents_code.update_check.is_update_check_enabled",
return_value=False,
),
patch(
"deepagents_code.managed_tools.ensure_ripgrep",
new=AsyncMock(return_value=None),
),
):
async with app.run_test() as pilot:
await pilot.pause()
await app._check_optional_tools_background()
await pilot.pause()
entry = app._notice_registry.get("dep:ripgrep")
assert entry is not None
assert app._notice_registry.toast_identity_for("dep:ripgrep") is None
async def test_update_check_skips_editable_install(self) -> None:
"""Editable installs skip update detection and never queue the modal."""
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
+127 -8
View File
@@ -66,7 +66,14 @@ class _AuthHostApp(App[None]):
yield Container(id="main")
def show_prompt(
self, provider: str, env_var: str | None, *, reason: str | None = None
self,
provider: str,
env_var: str | None,
*,
reason: str | None = None,
allow_empty_submit: bool = False,
input_placeholder: str | None = None,
submit_label: str | None = None,
) -> None:
"""Push the prompt and capture the dismissal result."""
@@ -74,7 +81,17 @@ class _AuthHostApp(App[None]):
self.prompt_result = result
self.prompt_dismissed = True
self.push_screen(AuthPromptScreen(provider, env_var, reason=reason), handle)
self.push_screen(
AuthPromptScreen(
provider,
env_var,
reason=reason,
allow_empty_submit=allow_empty_submit,
input_placeholder=input_placeholder,
submit_label=submit_label,
),
handle,
)
def show_manager(self) -> None:
"""Push the manager screen."""
@@ -234,7 +251,7 @@ class TestAuthPromptScreen:
toggle = app.screen.query_one("#auth-prompt-advanced-toggle", Static)
assert "Sign in to Anthropic" in str(instructions.content)
assert "create or copy an API key" in str(instructions.content)
assert "Provider key page" in str(instructions.content)
assert "Anthropic key page" in str(instructions.content)
assert "Deep Agents Code stores the above key locally" in str(
storage_note.content
)
@@ -369,7 +386,7 @@ api_key_env = "MY_GATEWAY_API_KEY"
await pilot.pause()
instructions = app.screen.query_one("#auth-prompt-key-instructions", Static)
assert "Sign in to My Gateway" in str(instructions.content)
assert "Provider key page" in str(instructions.content)
assert "My Gateway key page" in str(instructions.content)
async def test_unmapped_provider_links_to_setup_docs(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -390,8 +407,8 @@ api_key_env = "MY_GATEWAY_API_KEY"
instructions = app.screen.query_one("#auth-prompt-key-instructions", Static)
text = str(instructions.content)
assert "Sign in to My Gateway" in text
assert "Provider setup docs" in text
assert "Provider key page" not in text
assert "My Gateway setup docs" in text
assert "key page" not in text
async def test_unsafe_configured_key_url_is_not_rendered(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -417,8 +434,8 @@ api_key_env = "MY_GATEWAY_API_KEY"
# The malformed URL is dropped, so no clickable link survives.
assert not any("javascript" in str(span.style) for span in content.spans)
# With the configured URL rejected and no built-in entry, it falls
# back to the generic setup-docs link.
assert "Provider setup docs" in str(content)
# back to the setup-docs link.
assert "My Gateway setup docs" in str(content)
async def test_unsafe_configured_key_url_surfaces_notice(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -725,6 +742,108 @@ api_key_url = "javascript:alert(1)"
assert app.prompt_dismissed is False
assert auth_store.get_stored_key("anthropic") is None
async def test_optional_empty_submit_cancels_without_error(self) -> None:
"""Onboarding can use the auth prompt as an optional setup step."""
app = _AuthHostApp()
async with app.run_test() as pilot:
app.show_prompt(
"tavily",
"TAVILY_API_KEY",
allow_empty_submit=True,
input_placeholder="Tavily API key (optional)",
submit_label="Enter save/skip",
)
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert app.prompt_dismissed is True
assert app.prompt_result is AuthResult.CANCELLED
assert auth_store.get_stored_key("tavily") is None
async def test_optional_prompt_customizes_new_user_copy(self) -> None:
"""Onboarding can explain Tavily without a separate key-entry modal."""
app = _AuthHostApp()
async with app.run_test() as pilot:
app.show_prompt(
"tavily",
"TAVILY_API_KEY",
reason="Web search is optional. Press Enter to skip.",
allow_empty_submit=True,
input_placeholder="Tavily API key (optional)",
submit_label="Enter save/skip",
)
await pilot.pause()
key_input = app.screen.query_one("#auth-prompt-input", Input)
help_text = app.screen.query_one(".auth-prompt-help", Static)
copy = "\n".join(str(widget.content) for widget in app.screen.query(Static))
has_storage_note = bool(app.screen.query("#auth-prompt-storage-note"))
assert key_input.placeholder == "Tavily API key (optional)"
assert key_input.password is True
assert "Web search is optional" in copy
assert "Enter save/skip" in str(help_text.content)
# Services (Tavily) omit the storage note entirely — the title and
# reason already explain the key, so it would only be redundant copy.
assert has_storage_note is False
assert "stores the above key locally" not in copy
async def test_optional_prompt_surfaces_save_failure_and_stays_open(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A failed credential write shows an inline error and keeps the modal."""
def _raise(*_args: object, **_kwargs: object) -> auth_store.WriteOutcome:
msg = "credential store is not writable"
raise RuntimeError(msg)
monkeypatch.setattr(auth_store, "set_stored_key", _raise)
app = _AuthHostApp()
async with app.run_test() as pilot:
app.show_prompt("tavily", "TAVILY_API_KEY", allow_empty_submit=True)
await pilot.pause()
app.screen.query_one("#auth-prompt-input", Input).value = "tvly-key"
await pilot.press("enter")
await pilot.pause()
err = app.screen.query_one("#auth-prompt-error", Static)
error_text = str(err.content)
# Surfaced in-modal, not silently swallowed, and the modal stays open so
# onboarding cannot proceed as if the key were saved.
assert "Could not save credential" in error_text
assert "credential store is not writable" in error_text
assert app.prompt_dismissed is False
async def test_optional_prompt_notifies_store_warnings(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""chmod-style warnings from the store reach the user via `notify`."""
notices: list[tuple[str, str | None]] = []
def _capture_notify(
message: str, *_args: object, severity: str | None = None, **_kwargs: object
) -> None:
notices.append((str(message), severity))
def _warn(*_args: object, **_kwargs: object) -> auth_store.WriteOutcome:
return auth_store.WriteOutcome(warnings=("credential file is not private",))
monkeypatch.setattr(auth_store, "set_stored_key", _warn)
app = _AuthHostApp()
async with app.run_test() as pilot:
monkeypatch.setattr(app, "notify", _capture_notify)
app.show_prompt("tavily", "TAVILY_API_KEY", allow_empty_submit=True)
await pilot.pause()
app.screen.query_one("#auth-prompt-input", Input).value = "tvly-key"
await pilot.press("enter")
await pilot.pause()
assert ("credential file is not private", "warning") in notices
assert app.prompt_result is AuthResult.SAVED
async def test_escape_cancels(self) -> None:
"""Escape dismisses with `CANCELLED` and writes nothing."""
app = _AuthHostApp()