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