mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
feat(code): persistent banner when installation is stale (#4459)
Deep Agents Code now shows a persistent header banner when your installed version is more than two weeks old. --- Informs users when their `dcode` installation is more than two weeks old by reusing the existing persistent header bar (`_StaticHeader`, previously only shown behind `DEEPAGENTS_CODE_SHOW_HEADER` and used for the sandbox indicator). When the installed version's release is `>= 14` days old, the header is rendered even without the env var and its subtitle carries an advisory that overrides the sandbox label. Install age is derived cache-only (no startup network calls), and the check is suppressed for editable/dev installs and when update checks are disabled. Made by [Open SWE](https://openswe.vercel.app/agents/cca5fce9-67e0-cf5d-377f-d9acf912ed65) ## References - Plan: https://openswe.vercel.app/agents/cca5fce9-67e0-cf5d-377f-d9acf912ed65/plan --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -1985,7 +1985,8 @@ class DeepAgentsApp(App):
|
||||
defer_server_start: Whether to keep app-owned server startup paused
|
||||
until the user configures credentials or explicitly picks a model.
|
||||
title: Override the Textual `App.title` shown in the optional
|
||||
header bar.
|
||||
header bar (shown when `DEEPAGENTS_CODE_SHOW_HEADER` is set or
|
||||
the installation is stale).
|
||||
|
||||
When `None`, the class-level `TITLE` is used.
|
||||
|
||||
@@ -1993,7 +1994,8 @@ class DeepAgentsApp(App):
|
||||
sub_title: Override the Textual `App.sub_title` shown in the
|
||||
optional header bar.
|
||||
|
||||
When `None`, the parent default is used.
|
||||
When `None`, a sandbox label or a stale-install advisory may be
|
||||
substituted; otherwise the parent default is used.
|
||||
|
||||
Reassigning `app.sub_title` at runtime updates the header live.
|
||||
**kwargs: Additional arguments passed to parent
|
||||
@@ -2263,6 +2265,34 @@ class DeepAgentsApp(App):
|
||||
)
|
||||
self.sub_title = f"Sandbox: {display}"
|
||||
|
||||
from deepagents_code.update_check import (
|
||||
installed_days_old,
|
||||
is_installation_stale,
|
||||
)
|
||||
|
||||
self._installation_stale: bool = sub_title is None and is_installation_stale()
|
||||
"""Whether the installed version is old enough to force the header banner.
|
||||
|
||||
Set once at construction from the cache-only install-age check. When
|
||||
`True`, `compose` renders the header even without `DEEPAGENTS_CODE_SHOW_HEADER`
|
||||
and the subtitle carries the advisory below — overriding the sandbox
|
||||
subtitle by design.
|
||||
"""
|
||||
|
||||
if self._installation_stale:
|
||||
days = installed_days_old()
|
||||
if days is None:
|
||||
# The cache changed between the staleness gate and this read
|
||||
# (e.g. a concurrent `dcode` process). Drop the banner rather
|
||||
# than render "installed version is None days old".
|
||||
self._installation_stale = False
|
||||
else:
|
||||
unit = "day" if days == 1 else "days"
|
||||
self.sub_title = (
|
||||
f"Update available \u2014 installed version is "
|
||||
f"{days} {unit} old (run /update)"
|
||||
)
|
||||
|
||||
# Per-turn model overrides
|
||||
self._model_override: str | None = None
|
||||
"""Per-turn model override set via `/model`; `None` uses session default."""
|
||||
@@ -2770,7 +2800,7 @@ class DeepAgentsApp(App):
|
||||
"""
|
||||
from deepagents_code._env_vars import SHOW_HEADER, is_env_truthy
|
||||
|
||||
if is_env_truthy(SHOW_HEADER):
|
||||
if is_env_truthy(SHOW_HEADER) or self._installation_stale:
|
||||
yield _StaticHeader(id="app-header")
|
||||
# Main chat area with scrollable messages
|
||||
# VerticalScroll tracks user scroll intent for better auto-scroll behavior.
|
||||
@@ -16343,8 +16373,9 @@ async def run_textual_app(
|
||||
defer_server_start: Whether to keep app-owned server startup paused
|
||||
until credentials or a model are configured from inside the TUI.
|
||||
title: Override the Textual `App.title` shown in the optional header
|
||||
bar (gated on `DEEPAGENTS_CODE_SHOW_HEADER`). When `None`, the
|
||||
default `"Deep Agents"` is used.
|
||||
bar (gated on `DEEPAGENTS_CODE_SHOW_HEADER`, or shown automatically
|
||||
when the installation is stale). When `None`, the default
|
||||
`"Deep Agents"` is used.
|
||||
sub_title: Override the Textual `App.sub_title` shown in the optional
|
||||
header bar.
|
||||
|
||||
|
||||
@@ -69,6 +69,9 @@ call to PyPI; older payloads trigger a fresh fetch. Set conservatively at
|
||||
INSTALLED_AGE_NOTICE_DAYS = 7
|
||||
"""Minimum installed-version age before update notices call it out explicitly."""
|
||||
|
||||
INSTALLED_STALE_NOTICE_DAYS = 14
|
||||
"""Minimum installed-version age (days) before the stale-install banner shows."""
|
||||
|
||||
_SDK_RELEASE_TIMES_KEY = "sdk_release_times"
|
||||
"""`CACHE_FILE` key for cached SDK upload timestamps, keyed by version string."""
|
||||
|
||||
@@ -709,6 +712,48 @@ def format_installed_age_suffix(version: str | None) -> str:
|
||||
return f" ({days} {unit} old)"
|
||||
|
||||
|
||||
def installed_days_old() -> int | None:
|
||||
"""Return whole days since the installed version's release, or `None`.
|
||||
|
||||
Cache-only (`get_release_time`), so it never blocks on the network.
|
||||
Returns `None` when the release time is unknown (cold cache or an
|
||||
unparseable timestamp). Best-effort: any unexpected error degrades to
|
||||
`None` rather than propagating, since this runs on the startup path.
|
||||
"""
|
||||
try:
|
||||
return _days_old_from_iso(get_release_time(__version__))
|
||||
except Exception:
|
||||
logger.debug("Failed to compute installed version age", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def is_installation_stale() -> bool:
|
||||
"""Return whether an older installed version should warn about updating.
|
||||
|
||||
`True` only when a fresh cache says an update is available and the installed
|
||||
version's release is at least `INSTALLED_STALE_NOTICE_DAYS` old. Returns
|
||||
`False` for editable/dev installs (release age is meaningless there), when
|
||||
update checks are disabled (the opt-out silences this too), and when the age
|
||||
or update answer is unknown.
|
||||
|
||||
Best-effort: any unexpected error degrades to `False` rather than
|
||||
propagating, so this cosmetic check can never abort TUI startup.
|
||||
"""
|
||||
from deepagents_code.config import _is_editable_install
|
||||
|
||||
try:
|
||||
if _is_editable_install() or not is_update_check_enabled():
|
||||
return False
|
||||
available, _ = get_cached_update_available()
|
||||
if not available:
|
||||
return False
|
||||
days = installed_days_old()
|
||||
except Exception:
|
||||
logger.debug("Failed to determine installation staleness", exc_info=True)
|
||||
return False
|
||||
return days is not None and days >= INSTALLED_STALE_NOTICE_DAYS
|
||||
|
||||
|
||||
def get_sdk_release_time(
|
||||
version: str | None, *, bypass_cache: bool = False
|
||||
) -> str | None:
|
||||
|
||||
@@ -17074,6 +17074,121 @@ class TestSandboxSubTitle:
|
||||
assert app.sub_title == "Sandbox: Kubernetes"
|
||||
|
||||
|
||||
class TestStaleInstallBanner:
|
||||
"""The persistent header banner surfaces stale installations."""
|
||||
|
||||
_MESSAGE_21 = (
|
||||
"Update available \u2014 installed version is 21 days old (run /update)"
|
||||
)
|
||||
|
||||
async def test_header_mounted_when_stale_without_env_var(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A stale install shows the header even without the env var."""
|
||||
monkeypatch.delenv("DEEPAGENTS_CODE_SHOW_HEADER", raising=False)
|
||||
from textual.widgets import Header
|
||||
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.update_check.is_installation_stale",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.installed_days_old",
|
||||
return_value=21,
|
||||
),
|
||||
):
|
||||
app = DeepAgentsApp()
|
||||
assert app._installation_stale is True
|
||||
assert app.sub_title == self._MESSAGE_21
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert len(app.query(Header)) == 1
|
||||
|
||||
async def test_header_absent_when_not_stale(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A fresh install keeps the header hidden without the env var."""
|
||||
monkeypatch.delenv("DEEPAGENTS_CODE_SHOW_HEADER", raising=False)
|
||||
from textual.widgets import Header
|
||||
|
||||
with patch(
|
||||
"deepagents_code.update_check.is_installation_stale",
|
||||
return_value=False,
|
||||
):
|
||||
app = DeepAgentsApp()
|
||||
assert app._installation_stale is False
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert not app.query(Header)
|
||||
|
||||
async def test_stale_overrides_sandbox_sub_title(self) -> None:
|
||||
"""The stale-install advisory takes precedence over the sandbox label."""
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.update_check.is_installation_stale",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.installed_days_old",
|
||||
return_value=21,
|
||||
),
|
||||
):
|
||||
app = DeepAgentsApp(server_kwargs={"sandbox_type": "modal"})
|
||||
assert app.sub_title == self._MESSAGE_21
|
||||
|
||||
async def test_explicit_sub_title_suppresses_stale_banner(self) -> None:
|
||||
"""An explicitly passed sub_title is never overwritten by the advisory."""
|
||||
with patch(
|
||||
"deepagents_code.update_check.is_installation_stale",
|
||||
return_value=True,
|
||||
):
|
||||
app = DeepAgentsApp(sub_title="custom")
|
||||
assert app._installation_stale is False
|
||||
assert app.sub_title == "custom"
|
||||
|
||||
async def test_singular_day_wording(self) -> None:
|
||||
"""The day count is pluralized correctly."""
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.update_check.is_installation_stale",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.installed_days_old",
|
||||
return_value=1,
|
||||
),
|
||||
):
|
||||
app = DeepAgentsApp()
|
||||
assert app.sub_title == (
|
||||
"Update available \u2014 installed version is 1 day old (run /update)"
|
||||
)
|
||||
|
||||
async def test_none_days_drops_banner(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A `None` age (cache race) drops the banner, never `"None days old"`."""
|
||||
monkeypatch.delenv("DEEPAGENTS_CODE_SHOW_HEADER", raising=False)
|
||||
from textual.widgets import Header
|
||||
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.update_check.is_installation_stale",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.installed_days_old",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
app = DeepAgentsApp()
|
||||
assert app._installation_stale is False
|
||||
assert "None" not in (app.sub_title or "")
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert not app.query(Header)
|
||||
|
||||
|
||||
class TestHandleExternalSignal:
|
||||
"""Verify routing of `kind=signal` external events."""
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from deepagents_code._version import __version__
|
||||
from deepagents_code.extras_info import ExtrasIntrospectionError, installed_extra_names
|
||||
from deepagents_code.update_check import (
|
||||
CACHE_TTL,
|
||||
INSTALLED_STALE_NOTICE_DAYS,
|
||||
DependencyChange,
|
||||
InstallMethod,
|
||||
ShadowedDcode,
|
||||
@@ -61,8 +62,10 @@ from deepagents_code.update_check import (
|
||||
install_extra_recovery_command,
|
||||
install_extras_command,
|
||||
install_package_command,
|
||||
installed_days_old,
|
||||
is_auto_update_enabled,
|
||||
is_auto_update_explicitly_set,
|
||||
is_installation_stale,
|
||||
is_installed_version_at_least,
|
||||
is_update_available,
|
||||
is_valid_extra_name,
|
||||
@@ -1173,6 +1176,155 @@ class TestFormatInstalledAgeSuffix:
|
||||
assert format_installed_age_suffix("1.0.0") == ""
|
||||
|
||||
|
||||
def _write_installed_release_time(
|
||||
cache_file: Path, *, days_ago: int, latest_version: str | None = None
|
||||
) -> None:
|
||||
"""Seed the cache with a release time for the running version."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
iso = (datetime.now(tz=UTC) - timedelta(days=days_ago)).isoformat()
|
||||
data: dict[str, object] = {
|
||||
"release_times": {__version__: iso},
|
||||
"checked_at": time.time(),
|
||||
}
|
||||
if latest_version is not None:
|
||||
data["version"] = latest_version
|
||||
cache_file.write_text(
|
||||
json.dumps(data),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
class TestInstalledDaysOld:
|
||||
def test_returns_days_for_known_release(self, cache_file) -> None:
|
||||
_write_installed_release_time(cache_file, days_ago=21)
|
||||
assert installed_days_old() == 21
|
||||
|
||||
def test_unknown_release_returns_none(self, cache_file) -> None: # noqa: ARG002
|
||||
assert installed_days_old() is None
|
||||
|
||||
def test_malformed_timestamp_returns_none(self, cache_file: Path) -> None:
|
||||
cache_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"release_times": {__version__: "not-a-timestamp"},
|
||||
"checked_at": time.time(),
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert installed_days_old() is None
|
||||
|
||||
def test_none_when_release_lookup_raises(self, cache_file) -> None: # noqa: ARG002
|
||||
"""An unexpected error degrades to `None` instead of propagating."""
|
||||
with patch(
|
||||
"deepagents_code.update_check.get_release_time",
|
||||
side_effect=OSError("boom"),
|
||||
):
|
||||
assert installed_days_old() is None
|
||||
|
||||
|
||||
class TestIsInstallationStale:
|
||||
def test_true_when_older_than_threshold(self, cache_file) -> None:
|
||||
_write_installed_release_time(
|
||||
cache_file,
|
||||
days_ago=INSTALLED_STALE_NOTICE_DAYS + 1,
|
||||
latest_version="99.0.0",
|
||||
)
|
||||
with (
|
||||
patch("deepagents_code.config._is_editable_install", return_value=False),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_check_enabled",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
assert is_installation_stale() is True
|
||||
|
||||
def test_false_when_current_version_is_old(self, cache_file) -> None:
|
||||
_write_installed_release_time(
|
||||
cache_file,
|
||||
days_ago=INSTALLED_STALE_NOTICE_DAYS + 30,
|
||||
latest_version=__version__,
|
||||
)
|
||||
with (
|
||||
patch("deepagents_code.config._is_editable_install", return_value=False),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_check_enabled",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
assert is_installation_stale() is False
|
||||
|
||||
def test_true_at_exact_threshold(self, cache_file) -> None:
|
||||
"""Exactly `INSTALLED_STALE_NOTICE_DAYS` old is stale (inclusive `>=`)."""
|
||||
_write_installed_release_time(
|
||||
cache_file,
|
||||
days_ago=INSTALLED_STALE_NOTICE_DAYS,
|
||||
latest_version="99.0.0",
|
||||
)
|
||||
with (
|
||||
patch("deepagents_code.config._is_editable_install", return_value=False),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_check_enabled",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
assert is_installation_stale() is True
|
||||
|
||||
def test_false_when_newer_than_threshold(self, cache_file) -> None:
|
||||
_write_installed_release_time(
|
||||
cache_file,
|
||||
days_ago=INSTALLED_STALE_NOTICE_DAYS - 1,
|
||||
latest_version="99.0.0",
|
||||
)
|
||||
with (
|
||||
patch("deepagents_code.config._is_editable_install", return_value=False),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_check_enabled",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
assert is_installation_stale() is False
|
||||
|
||||
def test_false_when_editable_check_raises(self, cache_file) -> None: # noqa: ARG002
|
||||
"""A raising editable check degrades to `False`, never aborting startup."""
|
||||
with patch(
|
||||
"deepagents_code.config._is_editable_install",
|
||||
side_effect=PermissionError("direct_url.json unreadable"),
|
||||
):
|
||||
assert is_installation_stale() is False
|
||||
|
||||
def test_false_for_editable_install(self, cache_file) -> None:
|
||||
_write_installed_release_time(
|
||||
cache_file, days_ago=INSTALLED_STALE_NOTICE_DAYS + 30
|
||||
)
|
||||
with patch("deepagents_code.config._is_editable_install", return_value=True):
|
||||
assert is_installation_stale() is False
|
||||
|
||||
def test_false_when_update_checks_disabled(self, cache_file) -> None:
|
||||
_write_installed_release_time(
|
||||
cache_file, days_ago=INSTALLED_STALE_NOTICE_DAYS + 30
|
||||
)
|
||||
with (
|
||||
patch("deepagents_code.config._is_editable_install", return_value=False),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_check_enabled",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
assert is_installation_stale() is False
|
||||
|
||||
def test_false_on_cold_cache(self, cache_file) -> None: # noqa: ARG002
|
||||
with (
|
||||
patch("deepagents_code.config._is_editable_install", return_value=False),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_check_enabled",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
assert is_installation_stale() is False
|
||||
|
||||
|
||||
class TestDetectInstallMethod:
|
||||
def test_non_editable_non_uv_non_brew_returns_other(self) -> None:
|
||||
"""The fallback bucket is not a positive pip detection."""
|
||||
|
||||
Reference in New Issue
Block a user