mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
fix(code): unpin uv self-updates and warn when a stale dcode shadows PATH (#4185)
`uv tool upgrade` honors the requirement string baked into the uv tool
receipt, so a self-update could report success while silently
re-resolving the same pinned version — and even a genuine upgrade has no
effect if an older `dcode` sits earlier on the user's PATH. The update
path now forces an unpinned `uv tool install -U` and checks for a
shadowing binary after every upgrade, surfacing a copy-pasteable PATH
fix instead of a misleading "relaunch to use the new version."
## Changes
- **Unpinned uv upgrades.** `/update` and auto-update run `uv tool
install -U deepagents-code` instead of `uv tool upgrade`, clearing any
`==<version>` pin left in the receipt (from an original pinned install
or a prior dependency refresh) that would otherwise keep re-resolving
the same version. `upgrade_install_command` rebuilds the requirement
receipt-aware so installed extras, `--with` packages, and the recorded
`--python` survive; on receipt-introspection failure it falls back to
the bare command and emits a progress note that extras may not carry
over.
- **Shadowed-`dcode` detection.** `detect_shadowed_dcode` compares
`shutil.which("dcode")` against uv's documented executable directory
(`_uv_tool_bin_dir`, following the `UV_TOOL_BIN_DIR → XDG_BIN_HOME →
$XDG_DATA_HOME/../bin → ~/.local/bin` precedence). When a different
binary wins on PATH, `/update`, the "Install now" notification action,
and startup auto-update surface a warning naming both paths plus a
session-scoped `export PATH=…` fix from
`format_shadowed_dcode_fix_command`. The comparison is deliberately
against the un-followed PATH entry so healthy uv symlink shims aren't
flagged.
- **Startup auto-update skips the re-exec when shadowed.** Re-exec'ing
would relaunch the old binary and trip the no-op restart guard, so the
pre-launch path prints the warning and continues on the current version.
- **Detection can't sink a good upgrade.** All three call sites route
through `detect_shadowed_dcode_safe`, which logs and returns `None` on
any unexpected error, so a detector defect never turns a successful
upgrade into a user-facing failure.
- **Warning completion state for the progress modal.**
`UpdateProgressScreen.mark_warning` adds a third terminal state —
warning glyph, durable status, `c` to copy the fix command — distinct
from success/failure, and the "Install now" modal enters it instead of
the success state when the upgraded shim is shadowed.
## Testing
- Shadow detection is covered branch-by-branch: the paired symlink cases
(uv's own shim must not flag; a symlink in another PATH dir must), the
legacy `deepagents-code` name fallback, and `OSError` fault injection on
path resolution. `detect_shadowed_dcode_safe` is pinned to swallow an
unexpected raise.
- `upgrade_install_command` gets direct coverage of the `--python`
quoting and `--with` assembly that the `perform_upgrade` tests stub out,
and the modal's warning state asserts the durable status, glyph, and
copy-fix-command behavior.
This commit is contained in:
@@ -3910,10 +3910,12 @@ class DeepAgentsApp(App):
|
||||
from deepagents_code.update_check import (
|
||||
_PRERELEASE_UNSUPPORTED_MESSAGE,
|
||||
dependency_refresh_supported,
|
||||
detect_shadowed_dcode_safe,
|
||||
format_age_suffix,
|
||||
format_dependency_changes,
|
||||
format_installed_age_suffix,
|
||||
format_release_age_parenthetical,
|
||||
format_shadowed_dcode_warning,
|
||||
is_update_available,
|
||||
parse_dependency_changes,
|
||||
perform_dependency_refresh_dry_run,
|
||||
@@ -4050,13 +4052,27 @@ class DeepAgentsApp(App):
|
||||
)
|
||||
if success:
|
||||
self._update_available = (False, None)
|
||||
await self._mount_message(
|
||||
AppMessage(
|
||||
f"Updated to v{latest}. Quit and relaunch dcode to use "
|
||||
"the new version (`/restart` only restarts the server, "
|
||||
"not the CLI)."
|
||||
),
|
||||
)
|
||||
# uv may have installed the upgraded shim into a directory that
|
||||
# isn't first on the user's PATH (e.g. a leftover pre-uv
|
||||
# `dcode` from a former `pipx` install). Detect that before
|
||||
# mounting the success line so we don't follow a green
|
||||
# "relaunch to use the new version" with a warning that
|
||||
# relaunching will keep the old version. Use the
|
||||
# never-raises wrapper so a detector defect can't turn a
|
||||
# successful upgrade into a "/update failed" message.
|
||||
shadow = await asyncio.to_thread(detect_shadowed_dcode_safe)
|
||||
if shadow is None:
|
||||
await self._mount_message(
|
||||
AppMessage(
|
||||
f"Updated to v{latest}. Quit and relaunch dcode "
|
||||
"to use the new version (`/restart` only restarts "
|
||||
"the server, not the CLI)."
|
||||
),
|
||||
)
|
||||
else:
|
||||
await self._mount_message(
|
||||
ErrorMessage(format_shadowed_dcode_warning(shadow)),
|
||||
)
|
||||
# The upgrade re-resolves the whole environment, so surface any
|
||||
# dependency bumps that rode along with the dcode release.
|
||||
dep_changes = [
|
||||
@@ -10617,6 +10633,9 @@ class DeepAgentsApp(App):
|
||||
from deepagents_code.update_check import (
|
||||
clear_update_notified,
|
||||
create_update_log_path,
|
||||
detect_shadowed_dcode_safe,
|
||||
format_shadowed_dcode_fix_command,
|
||||
format_shadowed_dcode_warning,
|
||||
mark_update_notified,
|
||||
perform_upgrade,
|
||||
upgrade_command,
|
||||
@@ -10670,16 +10689,38 @@ class DeepAgentsApp(App):
|
||||
)
|
||||
if success:
|
||||
self._notice_registry.remove(entry.key)
|
||||
screen.mark_success()
|
||||
if not progress_modal_visible:
|
||||
# Same shadowing risk as `/update`: if a stale `dcode` is
|
||||
# earlier on PATH, the user's next launch will silently
|
||||
# run the old version. Surface that loudly even when only
|
||||
# a toast is visible. Keep the modal itself out of the
|
||||
# success state when relaunching would keep using the old
|
||||
# binary.
|
||||
shadow = await asyncio.to_thread(detect_shadowed_dcode_safe)
|
||||
if shadow is not None:
|
||||
warning = format_shadowed_dcode_warning(shadow)
|
||||
if progress_modal_visible:
|
||||
screen.mark_warning(
|
||||
warning,
|
||||
copy_text=format_shadowed_dcode_fix_command(shadow),
|
||||
)
|
||||
self.notify(
|
||||
f"Updated to v{payload.latest}. "
|
||||
"Quit and relaunch dcode to use the new version "
|
||||
"(/restart only restarts the server, not the CLI).",
|
||||
severity="information",
|
||||
timeout=10,
|
||||
warning,
|
||||
severity="warning",
|
||||
timeout=20,
|
||||
markup=False,
|
||||
)
|
||||
return
|
||||
screen.mark_success()
|
||||
if progress_modal_visible:
|
||||
return
|
||||
self.notify(
|
||||
f"Updated to v{payload.latest}. "
|
||||
"Quit and relaunch dcode to use the new version "
|
||||
"(/restart only restarts the server, not the CLI).",
|
||||
severity="information",
|
||||
timeout=10,
|
||||
markup=False,
|
||||
)
|
||||
return
|
||||
logger.warning(
|
||||
"Auto-upgrade failed for v%s. Output:\n%s",
|
||||
|
||||
@@ -195,7 +195,9 @@ def _run_startup_auto_update(console: "Console") -> None:
|
||||
from deepagents_code.config import _is_editable_install
|
||||
from deepagents_code.update_check import (
|
||||
create_update_log_path,
|
||||
detect_shadowed_dcode_safe,
|
||||
format_release_age_parenthetical,
|
||||
format_shadowed_dcode_warning,
|
||||
get_cached_update_available,
|
||||
is_auto_update_enabled,
|
||||
is_installed_version_at_least,
|
||||
@@ -295,6 +297,31 @@ def _run_startup_auto_update(console: "Console") -> None:
|
||||
)
|
||||
success, output = asyncio.run(perform_upgrade(log_path=log_path))
|
||||
if success:
|
||||
# If a stale `dcode` is earlier on PATH, the auto-restart would
|
||||
# re-exec into the old binary and the user would silently keep
|
||||
# running the pre-upgrade version. Detect that *before* the
|
||||
# re-exec so the warning isn't immediately wiped by the new
|
||||
# process's startup, and skip the restart — re-exec'ing into
|
||||
# an unchanged version would also trip the `restarted_for`
|
||||
# no-op loop guard on the next launch. Use the never-raises
|
||||
# wrapper so a detector defect can't crash startup after an
|
||||
# otherwise-successful upgrade.
|
||||
shadow = detect_shadowed_dcode_safe()
|
||||
if shadow is not None:
|
||||
# The warning embeds filesystem paths from `shutil.which`,
|
||||
# which can legally contain `[` (macOS/Linux). With
|
||||
# `markup=True` those would be parsed as Rich style tags,
|
||||
# so escape the warning before interpolation; the sibling
|
||||
# auto-update failure branch at the bottom of this function
|
||||
# escapes its uv output the same way.
|
||||
warning = format_shadowed_dcode_warning(shadow)
|
||||
console.print(
|
||||
f"[bold yellow]Warning:[/bold yellow] {escape(warning)}\n"
|
||||
f"Continuing with v{cli_version}.",
|
||||
highlight=False,
|
||||
markup=True,
|
||||
)
|
||||
return
|
||||
console.print(
|
||||
f"[green]Updated to v{latest}. Launching...[/green]",
|
||||
highlight=False,
|
||||
|
||||
@@ -24,6 +24,7 @@ import time
|
||||
import tomllib
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, NamedTuple, TextIO
|
||||
@@ -70,19 +71,33 @@ _SDK_RELEASE_TIMES_KEY = "sdk_release_times"
|
||||
|
||||
InstallMethod = Literal["uv", "brew", "other", "unknown"]
|
||||
|
||||
FALLBACK_UPGRADE_COMMAND = "uv tool upgrade deepagents-code"
|
||||
FALLBACK_UPGRADE_COMMAND = "uv tool install -U deepagents-code"
|
||||
"""Generic upgrade hint used when install-method detection fails.
|
||||
|
||||
Callers that surface an upgrade command in user-facing text should prefer
|
||||
`upgrade_command()`; this constant exists so those callers have something
|
||||
to render when detection raises unexpectedly. The documented install path
|
||||
is `uv tool install` (see `scripts/install.sh`), so the uv command is the
|
||||
right display fallback. Execution paths still refuse unrecognized installs
|
||||
instead of updating a separate environment.
|
||||
right display fallback. Uses `uv tool install -U` rather than `uv tool
|
||||
upgrade` for the same receipt-pin reason documented on `_UPGRADE_COMMANDS`:
|
||||
showing a user the `upgrade` form would hand them a command that silently
|
||||
stays on the old version for a pinned install. Execution paths still refuse
|
||||
unrecognized installs instead of updating a separate environment.
|
||||
"""
|
||||
|
||||
_UPGRADE_COMMANDS: dict[InstallMethod, str] = {
|
||||
"uv": "uv tool upgrade deepagents-code",
|
||||
# Use `uv tool install -U` instead of `uv tool upgrade`: the latter
|
||||
# *respects* the requirement string baked into the uv tool receipt by the
|
||||
# original install (or by any prior `dependency_refresh_command` that
|
||||
# wrote `deepagents-code==<old_version>` into the receipt). When that
|
||||
# requirement is pinned, `uv tool upgrade` "succeeds" but re-installs the
|
||||
# same pinned version, silently leaving the user behind latest. A bare
|
||||
# `uv tool install -U deepagents-code` rewrites the receipt's requirement
|
||||
# to an unpinned `deepagents-code` and re-resolves to the latest stable
|
||||
# release, which is what users running `/update` actually want.
|
||||
# `dependency_refresh_command` builds the inverse command for the
|
||||
# explicit "stay on this version, refresh deps" flow.
|
||||
"uv": FALLBACK_UPGRADE_COMMAND,
|
||||
"brew": "brew upgrade deepagents-code",
|
||||
}
|
||||
"""Upgrade commands keyed by install method.
|
||||
@@ -93,8 +108,12 @@ upgraded with a different package manager, because that can update a separate
|
||||
environment from the one currently providing `dcode`.
|
||||
"""
|
||||
|
||||
_UV_PRERELEASE_UPGRADE_COMMAND = "uv tool upgrade deepagents-code --prerelease allow"
|
||||
"""uv upgrade command that opts into alpha/beta/rc release resolution."""
|
||||
_UV_PRERELEASE_UPGRADE_COMMAND = f"{FALLBACK_UPGRADE_COMMAND} --prerelease allow"
|
||||
"""uv upgrade command that opts into alpha/beta/rc release resolution.
|
||||
|
||||
Uses `uv tool install -U` (not `uv tool upgrade`) for the same receipt-pin
|
||||
reason documented on `_UPGRADE_COMMANDS`.
|
||||
"""
|
||||
|
||||
_PRERELEASE_UNSUPPORTED_MESSAGE = (
|
||||
"Pre-release updates aren't supported for this install. Reinstall with "
|
||||
@@ -882,6 +901,290 @@ def dependency_refresh_supported(
|
||||
return False, _DEPENDENCY_REFRESH_UNSUPPORTED[method]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ShadowedDcode:
|
||||
"""A different dcode entry point is winning on PATH than the one we upgraded.
|
||||
|
||||
Returned by `detect_shadowed_dcode` after a successful upgrade so the TUI can
|
||||
warn the user that re-launching will pick up the wrong binary. The most
|
||||
common cause is a pre-uv install (e.g. a leftover from a previous
|
||||
`pipx`/`pip`-based install) earlier on `PATH` than the uv tool shims.
|
||||
|
||||
A frozen dataclass rather than a `NamedTuple` (unlike the sibling
|
||||
`DependencyChange`) so `__post_init__` can enforce the conflict invariant
|
||||
the type's name promises: an instance only exists when there genuinely is
|
||||
a shadow. The producer already guarantees this, so the check is defensive
|
||||
against future direct construction, not a runtime gate on the hot path.
|
||||
"""
|
||||
|
||||
shadowing_bin: Path
|
||||
"""Absolute path to the `dcode` (or `deepagents-code`) binary the user's
|
||||
`PATH` currently resolves first — the file their next `dcode` will run.
|
||||
|
||||
Reported as the un-followed `shutil.which` result rather than its symlink
|
||||
target, since that's the file the user needs to either delete or demote
|
||||
on `PATH`.
|
||||
"""
|
||||
|
||||
upgraded_bin_dir: Path
|
||||
"""Absolute path to the bin directory uv installed the upgraded shim into.
|
||||
|
||||
Resolved via uv's documented executable-directory precedence (see
|
||||
`_uv_tool_bin_dir`).
|
||||
"""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Reject a non-conflict instance — the type's namesake invariant.
|
||||
|
||||
If the shadowing binary already lives in the upgraded bin dir there is
|
||||
no shadow, and a warning built from it would tell the user a binary
|
||||
shadows itself. `detect_shadowed_dcode` returns `None` in that case, so
|
||||
this only fires on a misconstructed instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If `shadowing_bin` already resides in `upgraded_bin_dir`.
|
||||
"""
|
||||
if self.shadowing_bin.parent == self.upgraded_bin_dir:
|
||||
msg = (
|
||||
f"ShadowedDcode requires a real shadow, but {self.shadowing_bin} "
|
||||
f"already resides in the upgraded bin dir {self.upgraded_bin_dir}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
@property
|
||||
def upgraded_bin(self) -> Path:
|
||||
"""Absolute path to the upgraded `dcode` shim uv installed.
|
||||
|
||||
Keeps the `dcode` entry-point name owned by the type rather than
|
||||
re-derived at each call site (mirrors `DependencyChange.kind`).
|
||||
"""
|
||||
return self.upgraded_bin_dir / "dcode"
|
||||
|
||||
|
||||
def _uv_tool_bin_dir() -> Path | None:
|
||||
"""Return the bin directory uv installed the running `dcode` shim into.
|
||||
|
||||
Mirrors uv's documented executable-directory precedence so a custom
|
||||
layout (e.g. `XDG_BIN_HOME` set on Linux) is compared against the same
|
||||
directory uv would install into. Following uv's reference at
|
||||
https://docs.astral.sh/uv/reference/storage/#executable-directory:
|
||||
|
||||
The precedence (single, unbranched code path): `UV_TOOL_BIN_DIR` →
|
||||
`XDG_BIN_HOME` → `$XDG_DATA_HOME/../bin` → the final `.local/bin` under
|
||||
the home directory. The last candidate is `Path.home() / ".local" / "bin"`,
|
||||
which `pathlib` resolves per-platform — `$HOME/.local/bin` on Unix and
|
||||
`%USERPROFILE%/.local/bin` on Windows — so one expression satisfies uv's
|
||||
documented fallback on both without an `os.name` branch here.
|
||||
|
||||
The first candidate that exists as a directory wins; an existing but
|
||||
unusable candidate (read failures, race) is skipped so a transient
|
||||
glitch doesn't downgrade the answer to a less-preferred path.
|
||||
|
||||
Returns:
|
||||
The absolute, resolved bin directory, or `None` when no candidate
|
||||
exists (e.g. a CI install that never created `~/.local/bin`).
|
||||
"""
|
||||
|
||||
def _from_env(name: str) -> Path | None:
|
||||
raw = os.environ.get(name)
|
||||
return Path(raw).expanduser() if raw else None
|
||||
|
||||
home = Path.home()
|
||||
xdg_data_home_str = os.environ.get("XDG_DATA_HOME")
|
||||
xdg_data_home = (
|
||||
Path(xdg_data_home_str).expanduser()
|
||||
if xdg_data_home_str
|
||||
else home / ".local" / "share"
|
||||
)
|
||||
|
||||
# Build candidates in uv's documented precedence order. None entries
|
||||
# (env var unset) are filtered out before iteration so each remaining
|
||||
# candidate is a real path.
|
||||
candidates: list[Path | None] = [
|
||||
_from_env("UV_TOOL_BIN_DIR"),
|
||||
_from_env("XDG_BIN_HOME"),
|
||||
# `$XDG_DATA_HOME/../bin` — uv's documented intermediate fallback.
|
||||
# Falls back to the spec default (`~/.local/share`) when the env
|
||||
# var is unset; the resulting `~/.local/share/../bin` = `~/.local/bin`
|
||||
# matches the final fallback below, which is fine — only the first
|
||||
# match wins.
|
||||
xdg_data_home.parent / "bin",
|
||||
home / ".local" / "bin",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate is None:
|
||||
continue
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
except OSError:
|
||||
logger.debug(
|
||||
"Could not resolve uv tool bin dir candidate %s",
|
||||
candidate,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
if resolved.is_dir():
|
||||
return resolved
|
||||
return None
|
||||
|
||||
|
||||
def detect_shadowed_dcode() -> ShadowedDcode | None:
|
||||
"""Return the shadowing dcode entry point on the user's PATH, if any.
|
||||
|
||||
After a successful `uv tool upgrade`, the upgraded binary only takes effect
|
||||
on the next launch if the user's `PATH` resolves to uv's tool bin dir for
|
||||
`dcode` (and `deepagents-code`). A pre-uv install earlier on `PATH` will
|
||||
silently win and report the old version, which looks like "the upgrade
|
||||
didn't work" to the user.
|
||||
|
||||
This compares each supported console script against uv's tool bin dir. A
|
||||
mismatch means a different binary will run next launch for that entry point.
|
||||
|
||||
Caveat: a `dcode` symlink that lives in some unrelated bin dir but
|
||||
points *into* the upgraded tool venv (e.g. a manually-created
|
||||
convenience symlink) is reported as shadowing even though the next
|
||||
launch would actually run the upgraded entry point. Comparing
|
||||
directories rather than resolved targets is intentional — see the
|
||||
inline note below for why — and this edge is rare enough that we
|
||||
accept a benign false positive over a class of false negatives.
|
||||
|
||||
Returns:
|
||||
A `ShadowedDcode` describing the conflict, or `None` when there is no
|
||||
shadowing binary (the common case) or when detection is not
|
||||
applicable (non-uv install, uv bin dir unknown, no supported entry
|
||||
point on `PATH` at all).
|
||||
"""
|
||||
if detect_install_method() != "uv":
|
||||
return None
|
||||
upgraded_bin_dir = _uv_tool_bin_dir()
|
||||
if upgraded_bin_dir is None:
|
||||
return None
|
||||
# Check every supported entry point. One healthy command name does not
|
||||
# prove another command name cannot still be shadowed earlier on PATH.
|
||||
for name in ("dcode", "deepagents-code"):
|
||||
resolved = shutil.which(name)
|
||||
if resolved is None:
|
||||
continue
|
||||
# Compare the *PATH-entry directory* against uv's bin dir, NOT the
|
||||
# symlink target. uv exposes its tool entry points as symlinks under
|
||||
# the user's bin dir (e.g. `~/.local/bin/dcode` -> the tool venv at
|
||||
# `~/.local/share/uv/tools/deepagents-code/bin/dcode`). Following the
|
||||
# link would make every healthy uv install look shadowed, because the
|
||||
# resolved parent is the tool venv's bin dir rather than the
|
||||
# PATH-visible one. Take the parent of the un-followed `which`
|
||||
# result so we answer the question we actually care about: "is uv's
|
||||
# bin dir what PATH resolves to?" `Path(...).parent` does not follow
|
||||
# the file's symlink. Only the directory is canonicalized, so
|
||||
# benign filesystem aliases (case folding, /private/var vs /var on
|
||||
# macOS, mount-point synonyms) still compare equal.
|
||||
path_dir = Path(resolved).parent
|
||||
try:
|
||||
canonical_path_dir = path_dir.resolve()
|
||||
except OSError:
|
||||
# Couldn't canonicalize the PATH-entry directory (e.g. a stale
|
||||
# symlink, a vanished mount). Returning `None` here would
|
||||
# silently hide a real shadow, so continue to the next candidate
|
||||
# name if any; if this was the last (`deepagents-code`), the loop
|
||||
# falls through to `None` — an indeterminate result we'd rather
|
||||
# surface to a developer than mask, hence `warning`, not `debug`.
|
||||
logger.warning(
|
||||
"Could not resolve PATH directory for %s at %s",
|
||||
name,
|
||||
path_dir,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
if canonical_path_dir == upgraded_bin_dir:
|
||||
# This entry point resolves to the directory uv just wrote into.
|
||||
# Keep checking the other supported entry point before declaring
|
||||
# there is no shadow.
|
||||
continue
|
||||
return ShadowedDcode(
|
||||
shadowing_bin=Path(resolved),
|
||||
upgraded_bin_dir=upgraded_bin_dir,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def detect_shadowed_dcode_safe() -> ShadowedDcode | None:
|
||||
"""Best-effort `detect_shadowed_dcode` that never raises.
|
||||
|
||||
The shadow check only ever runs to decorate an already-successful upgrade,
|
||||
so a defect in detection — or an unexpected error escaping the narrow
|
||||
`OSError` guards inside `detect_shadowed_dcode` — must not turn a working
|
||||
upgrade into a user-facing failure. Any unexpected exception is logged and
|
||||
treated as "no shadow detected", matching the fail-open bias the detector
|
||||
already applies internally.
|
||||
|
||||
Returns:
|
||||
Whatever `detect_shadowed_dcode` returns, or `None` if it raised.
|
||||
"""
|
||||
try:
|
||||
return detect_shadowed_dcode()
|
||||
except Exception:
|
||||
logger.warning("Shadow detection failed after upgrade", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def format_shadowed_dcode_warning(shadow: ShadowedDcode) -> str:
|
||||
"""Render a user-facing warning for a shadowed-dcode situation.
|
||||
|
||||
Shared by the `/update` slash command, the update-notification "Install
|
||||
now" action, and the pre-launch auto-update path so the wording stays
|
||||
consistent.
|
||||
|
||||
Args:
|
||||
shadow: The shadowing-binary description returned by
|
||||
`detect_shadowed_dcode`.
|
||||
|
||||
Returns:
|
||||
A plain-text, multi-line warning suitable for either the TUI message
|
||||
stream or a Rich `console.print`.
|
||||
"""
|
||||
fix_command = format_shadowed_dcode_fix_command(shadow)
|
||||
indented_command = fix_command.replace("\n", "\n ")
|
||||
return (
|
||||
"Update installed, but another `dcode` is earlier on your PATH and "
|
||||
"will keep running the old version on relaunch:\n"
|
||||
f" Shadowing binary: {shadow.shadowing_bin}\n"
|
||||
f" Upgraded shim: {shadow.upgraded_bin}\n"
|
||||
"After closing dcode, run this to make the upgraded shim win in this "
|
||||
"terminal:\n"
|
||||
f" {indented_command}\n"
|
||||
"Then relaunch dcode. To make the fix permanent, add the PATH change "
|
||||
"to your shell profile, or uninstall the older dcode if you no longer "
|
||||
"need it."
|
||||
)
|
||||
|
||||
|
||||
def format_shadowed_dcode_fix_command(shadow: ShadowedDcode) -> str:
|
||||
"""Return a session-scoped shell command to prefer the upgraded shim.
|
||||
|
||||
The command targets the shell that matches the current platform: PowerShell
|
||||
on Windows (where `_uv_tool_bin_dir` can resolve `%USERPROFILE%/.local/bin`
|
||||
and `export`/`hash` are not valid), and POSIX `sh`/`bash`/`zsh` elsewhere.
|
||||
|
||||
Args:
|
||||
shadow: The shadowing-binary description returned by
|
||||
`detect_shadowed_dcode`.
|
||||
|
||||
Returns:
|
||||
A copy-pasteable shell command that updates only the current terminal
|
||||
session and, on POSIX, clears the shell's command-path cache.
|
||||
"""
|
||||
bin_dir = str(shadow.upgraded_bin_dir)
|
||||
if os.name == "nt":
|
||||
# PowerShell, the default Windows shell. Single-quote the literal path so
|
||||
# `$`, `$()`, and backticks in directory names are not expanded, then
|
||||
# concatenate the live session PATH outside the literal. No cache flush is
|
||||
# needed — PowerShell resolves executables per invocation rather than
|
||||
# caching like POSIX shells' `hash`.
|
||||
quoted = bin_dir.replace("'", "''")
|
||||
return f"$env:PATH = '{quoted};' + $env:PATH"
|
||||
quoted = shlex.quote(bin_dir)
|
||||
return f"export PATH={quoted}:$PATH\nhash -r 2>/dev/null || true"
|
||||
|
||||
|
||||
def cleanup_update_logs(
|
||||
*,
|
||||
retention_days: int = UPDATE_LOG_RETENTION_DAYS,
|
||||
@@ -1092,13 +1395,61 @@ async def perform_upgrade(
|
||||
if not supported:
|
||||
return False, reason or _PRERELEASE_UNSUPPORTED_MESSAGE
|
||||
|
||||
cmd = upgrade_command(method, include_prereleases=resolved_include_prereleases)
|
||||
fell_back_to_bare_command = False
|
||||
if method == "uv":
|
||||
# Prefer the receipt-aware `uv tool install -U` builder so installed
|
||||
# extras / `--with` packages survive the upgrade and any stale
|
||||
# `==<version>` pin in the receipt is cleared. Fall back to the bare
|
||||
# display command when extras or receipt introspection fails — the
|
||||
# fallback might drop extras, but a successful unpinned upgrade is
|
||||
# still strictly better than a pinned "upgrade" that quietly stays
|
||||
# on the old version.
|
||||
from deepagents_code.extras_info import ExtrasIntrospectionError
|
||||
|
||||
try:
|
||||
cmd = upgrade_install_command(
|
||||
include_prereleases=resolved_include_prereleases,
|
||||
)
|
||||
except (ExtrasIntrospectionError, ToolRequirementIntrospectionError) as exc:
|
||||
logger.warning(
|
||||
"Could not build receipt-aware uv upgrade command (%s: %s); "
|
||||
"falling back to the bare command. Installed extras may be "
|
||||
"dropped.",
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
fell_back_to_bare_command = True
|
||||
cmd = upgrade_command(
|
||||
method,
|
||||
include_prereleases=resolved_include_prereleases,
|
||||
)
|
||||
else:
|
||||
cmd = upgrade_command(
|
||||
method,
|
||||
include_prereleases=resolved_include_prereleases,
|
||||
)
|
||||
|
||||
# Skip brew if binary not on PATH
|
||||
if method == "brew" and not shutil.which("brew"):
|
||||
return False, "brew not found on PATH."
|
||||
|
||||
return await _run_install_subprocess(cmd, progress=progress, log_path=log_path)
|
||||
success, output = await _run_install_subprocess(
|
||||
cmd, progress=progress, log_path=log_path
|
||||
)
|
||||
if success and fell_back_to_bare_command:
|
||||
# Surface the dropped-extras caveat only now that the bare upgrade has
|
||||
# actually succeeded. Emitting it before `_run_install_subprocess` ran
|
||||
# would misfire on a failed upgrade — telling the user to re-add extras
|
||||
# for an install that was left untouched. The log line above is
|
||||
# invisible in the TUI, so the progress stream is the user's only window
|
||||
# into this.
|
||||
await _emit_progress(
|
||||
progress,
|
||||
"Note: couldn't read your full install configuration; "
|
||||
"installed extras or extra packages may not carry over. "
|
||||
"Re-add them if a feature stops working after relaunch.",
|
||||
)
|
||||
return success, output
|
||||
|
||||
|
||||
async def perform_dependency_refresh(
|
||||
@@ -1516,6 +1867,84 @@ def _dcode_extras_requirement(
|
||||
return shlex.quote(requirement)
|
||||
|
||||
|
||||
def _uv_tool_install_command(
|
||||
*,
|
||||
version: str | None,
|
||||
include_prereleases: bool | None,
|
||||
distribution_name: str,
|
||||
) -> str:
|
||||
"""Return the receipt-preserving `uv tool install -U` command.
|
||||
|
||||
Raises:
|
||||
ExtrasIntrospectionError: If a metadata-sourced extra name fails PEP 508
|
||||
validation.
|
||||
"""
|
||||
from deepagents_code.extras_info import (
|
||||
ExtrasIntrospectionError,
|
||||
installed_extra_names,
|
||||
)
|
||||
|
||||
extras = installed_extra_names(distribution_name, strict=True)
|
||||
try:
|
||||
requirement = _dcode_extras_requirement(extras, version=version)
|
||||
except ValueError as exc:
|
||||
msg = f"Distribution metadata yielded an invalid extra name: {exc}"
|
||||
raise ExtrasIntrospectionError(msg) from exc
|
||||
cmd = "uv tool install -U"
|
||||
python = _uv_tool_python()
|
||||
if python is not None:
|
||||
cmd += f" --python {shlex.quote(python)}"
|
||||
cmd += f" {requirement}"
|
||||
with_packages = _uv_tool_with_packages(distribution_name=distribution_name)
|
||||
for package in with_packages:
|
||||
cmd += f" --with {shlex.quote(package)}"
|
||||
if _resolve_include_prereleases(include_prereleases):
|
||||
cmd += " --prerelease allow"
|
||||
return cmd
|
||||
|
||||
|
||||
def upgrade_install_command(
|
||||
*,
|
||||
include_prereleases: bool | None = None,
|
||||
distribution_name: str = "deepagents-code",
|
||||
) -> str:
|
||||
"""Return the uv command that upgrades dcode while clearing any version pin.
|
||||
|
||||
Built specifically to avoid the `uv tool upgrade` receipt-pin trap: when
|
||||
the tool was originally installed via `uv tool install deepagents-code==X.Y.Z`
|
||||
— or when a prior `dependency_refresh_command` rewrote the receipt with a
|
||||
version-pinned requirement — `uv tool upgrade deepagents-code` will only
|
||||
re-resolve *within* that pin and silently keep the user on the same
|
||||
version. Re-running `uv tool install -U deepagents-code[<extras>]` (no
|
||||
version pin) rewrites the receipt's requirement to unpinned so the next
|
||||
upgrade can actually move forward. Installed extras and `--with`
|
||||
packages are preserved to mirror `dependency_refresh_command`; only the
|
||||
version pin is intentionally stripped.
|
||||
|
||||
Args:
|
||||
include_prereleases: Whether to include alpha/beta/rc releases. When
|
||||
`None`, follows the installed version's channel.
|
||||
distribution_name: Name of the installed distribution to inspect for
|
||||
already-installed extras.
|
||||
|
||||
Returns:
|
||||
Shell command string suitable for execution via the shell.
|
||||
|
||||
Propagates `ExtrasIntrospectionError` if installed extras cannot be
|
||||
determined safely from distribution metadata, or a metadata-sourced extra name
|
||||
fails PEP 508 validation. Also propagates `ToolRequirementIntrospectionError`
|
||||
if the uv tool `--with` packages or interpreter cannot be determined safely
|
||||
from the tool receipt. Callers choose whether to treat those errors as
|
||||
failures or fall back to a simpler unpinned upgrade command with a
|
||||
user-facing warning.
|
||||
"""
|
||||
return _uv_tool_install_command(
|
||||
version=None,
|
||||
include_prereleases=include_prereleases,
|
||||
distribution_name=distribution_name,
|
||||
)
|
||||
|
||||
|
||||
def dependency_refresh_command(
|
||||
*,
|
||||
version: str = __version__,
|
||||
@@ -1535,30 +1964,17 @@ def dependency_refresh_command(
|
||||
Shell command string suitable for execution via the shell.
|
||||
|
||||
Propagates `ExtrasIntrospectionError` if installed extras cannot be
|
||||
determined safely from distribution metadata, and
|
||||
`ToolRequirementIntrospectionError` if the uv tool `--with` packages or
|
||||
interpreter cannot be determined safely from the tool receipt.
|
||||
`perform_dependency_refresh` converts both into a user-facing failure.
|
||||
determined safely from distribution metadata, or a metadata-sourced extra name
|
||||
fails PEP 508 validation, and `ToolRequirementIntrospectionError` if the uv
|
||||
tool `--with` packages or interpreter cannot be determined safely from the
|
||||
tool receipt. `perform_dependency_refresh` converts both into a user-facing
|
||||
failure.
|
||||
"""
|
||||
from deepagents_code.extras_info import installed_extra_names
|
||||
|
||||
# `installed_extra_names` raises `ExtrasIntrospectionError`; `_uv_tool_python`
|
||||
# and `_uv_tool_with_packages` raise `ToolRequirementIntrospectionError`.
|
||||
# Both propagate to `perform_dependency_refresh`, which converts them into a
|
||||
# user-facing failure, so there's nothing to add by catching here.
|
||||
extras = installed_extra_names(distribution_name, strict=True)
|
||||
requirement = _dcode_extras_requirement(extras, version=version)
|
||||
cmd = "uv tool install -U"
|
||||
python = _uv_tool_python()
|
||||
if python is not None:
|
||||
cmd += f" --python {shlex.quote(python)}"
|
||||
cmd += f" {requirement}"
|
||||
with_packages = _uv_tool_with_packages(distribution_name=distribution_name)
|
||||
for package in with_packages:
|
||||
cmd += f" --with {shlex.quote(package)}"
|
||||
if _resolve_include_prereleases(include_prereleases):
|
||||
cmd += " --prerelease allow"
|
||||
return cmd
|
||||
return _uv_tool_install_command(
|
||||
version=version,
|
||||
include_prereleases=include_prereleases,
|
||||
distribution_name=distribution_name,
|
||||
)
|
||||
|
||||
|
||||
def dependency_refresh_dry_run_command(
|
||||
|
||||
@@ -126,6 +126,7 @@ class UpdateProgressScreen(ModalScreen[None]):
|
||||
self._details_visible = False
|
||||
self._done = False
|
||||
self._status = f"Installing v{latest}..."
|
||||
self._done_glyph: str | None = None
|
||||
self._status_widget: Static | None = None
|
||||
self._spinner = Spinner()
|
||||
self._spinner_widget: Static | None = None
|
||||
@@ -134,6 +135,8 @@ class UpdateProgressScreen(ModalScreen[None]):
|
||||
self._log_path_widget: Static | None = None
|
||||
self._tail_widget: Log | None = None
|
||||
self._help_widget: Static | None = None
|
||||
self._copy_text: str | None = None
|
||||
self._copy_label = "log path"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the modal.
|
||||
@@ -196,6 +199,7 @@ class UpdateProgressScreen(ModalScreen[None]):
|
||||
def mark_success(self) -> None:
|
||||
"""Render the completed-success state."""
|
||||
self._done = True
|
||||
self._done_glyph = get_glyphs().checkmark
|
||||
self._status = (
|
||||
f"Update complete. Quit and relaunch Deep Agents Code to use "
|
||||
f"v{self._latest}."
|
||||
@@ -210,12 +214,37 @@ class UpdateProgressScreen(ModalScreen[None]):
|
||||
command: Manual command users can run to retry.
|
||||
"""
|
||||
self._done = True
|
||||
self._done_glyph = get_glyphs().error
|
||||
self._status = f"Update failed. Try manually: {command}"
|
||||
self._details_visible = True
|
||||
self._stop_spinner_timer()
|
||||
self._refresh_status()
|
||||
self._apply_details_visibility()
|
||||
|
||||
def mark_warning(
|
||||
self,
|
||||
warning: str,
|
||||
*,
|
||||
copy_text: str | None = None,
|
||||
copy_label: str = "fix command",
|
||||
) -> None:
|
||||
"""Render a completed state that needs user action.
|
||||
|
||||
Args:
|
||||
warning: Durable warning text to show in the modal status.
|
||||
copy_text: Optional text copied by the `c` key in warning state.
|
||||
copy_label: User-facing name for the copied text.
|
||||
"""
|
||||
self._done = True
|
||||
self._done_glyph = get_glyphs().warning
|
||||
self._status = warning
|
||||
self._copy_text = copy_text
|
||||
self._copy_label = copy_label
|
||||
self._details_visible = True
|
||||
self._stop_spinner_timer()
|
||||
self._refresh_status()
|
||||
self._apply_details_visibility()
|
||||
|
||||
def action_toggle_details(self) -> None:
|
||||
"""Show or hide the live log tail."""
|
||||
self._details_visible = not self._details_visible
|
||||
@@ -227,12 +256,19 @@ class UpdateProgressScreen(ModalScreen[None]):
|
||||
self.dismiss(None)
|
||||
|
||||
def action_copy_log_path(self) -> None:
|
||||
"""Copy the persisted log path when details are visible."""
|
||||
if not self._details_visible:
|
||||
"""Copy warning action text or the persisted log path."""
|
||||
copy_text = self._copy_text
|
||||
copy_label = self._copy_label
|
||||
if copy_text is None:
|
||||
if not self._details_visible:
|
||||
return
|
||||
copy_text = str(self._log_path)
|
||||
copy_label = "log path"
|
||||
if not copy_text:
|
||||
return
|
||||
self.app.copy_to_clipboard(str(self._log_path))
|
||||
self.app.copy_to_clipboard(copy_text)
|
||||
self.app.notify(
|
||||
"Copied log path.",
|
||||
f"Copied {copy_label}.",
|
||||
severity="information",
|
||||
timeout=3,
|
||||
markup=False,
|
||||
@@ -243,7 +279,7 @@ class UpdateProgressScreen(ModalScreen[None]):
|
||||
self._status_widget.update(self._status)
|
||||
if self._spinner_widget is not None:
|
||||
if self._done:
|
||||
self._spinner_widget.update(get_glyphs().checkmark)
|
||||
self._spinner_widget.update(self._done_glyph or get_glyphs().checkmark)
|
||||
self._spinner_widget.display = True
|
||||
else:
|
||||
self._spinner_widget.display = True
|
||||
@@ -271,7 +307,9 @@ class UpdateProgressScreen(ModalScreen[None]):
|
||||
details = "Hide details" if self._details_visible else "Show details"
|
||||
close = "Esc close" if self._done else "Esc close when complete"
|
||||
parts = [f"d {details}", close]
|
||||
if self._details_visible:
|
||||
if self._copy_text is not None:
|
||||
parts.insert(1, f"c copy {self._copy_label}")
|
||||
elif self._details_visible:
|
||||
parts.insert(1, "c copy log path")
|
||||
return f" {glyphs.bullet} ".join(parts)
|
||||
|
||||
|
||||
@@ -9780,6 +9780,23 @@ class TestNotificationCenterIntegration:
|
||||
):
|
||||
yield
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_shadowed_dcode(self) -> Iterator[None]:
|
||||
"""Default to no PATH shadow for the notification-action tests.
|
||||
|
||||
Without this, every successful "Install now" test runs the real
|
||||
`detect_shadowed_dcode` against the host filesystem. The runner's
|
||||
editable install currently short-circuits at `detect_install_method()`,
|
||||
but a uv-tool-managed runner would silently re-route every success
|
||||
case through the new warning branch. Pin to `None` here; the
|
||||
dedicated shadow-present test below overrides it.
|
||||
"""
|
||||
with patch(
|
||||
"deepagents_code.update_check.detect_shadowed_dcode",
|
||||
return_value=None,
|
||||
):
|
||||
yield
|
||||
|
||||
async def test_ctrl_n_with_empty_registry_emits_toast(self) -> None:
|
||||
"""ctrl+n with nothing pending notifies and doesn't push a modal."""
|
||||
from deepagents_code.notifications import NotificationRegistry
|
||||
@@ -10291,6 +10308,154 @@ class TestNotificationCenterIntegration:
|
||||
|
||||
assert app._notice_registry.get("update:available") is None
|
||||
|
||||
async def test_install_success_with_shadow_surfaces_warning(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""When PATH is shadowed, modal success is replaced by the warning.
|
||||
|
||||
Regression guard for the inverted `if/elif` in the install-action
|
||||
branch: a `if shadow / elif not progress_modal_visible` that got
|
||||
flipped would ship a reassuring "Updated to vX.Y.Z" state over a
|
||||
broken upgrade. This pins the contract that the success modal status
|
||||
and toast are suppressed while the warning stays visible.
|
||||
"""
|
||||
from deepagents_code.notifications import ActionId
|
||||
from deepagents_code.update_check import (
|
||||
ShadowedDcode,
|
||||
format_shadowed_dcode_fix_command,
|
||||
)
|
||||
from deepagents_code.widgets.update_progress import UpdateProgressScreen
|
||||
|
||||
shadow = ShadowedDcode(
|
||||
shadowing_bin=Path("/opt/stale/bin/dcode"),
|
||||
upgraded_bin_dir=Path("/home/user/.local/bin"),
|
||||
)
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
|
||||
entry = _update_entry()
|
||||
app._notice_registry.add(entry)
|
||||
|
||||
copied: list[str] = []
|
||||
notified: list[str] = []
|
||||
original_notify = app.notify
|
||||
monkeypatch.setattr(app, "copy_to_clipboard", copied.append)
|
||||
|
||||
def capture_notify(message: str, **kwargs: Any) -> None:
|
||||
notified.append(message)
|
||||
original_notify(message, **kwargs)
|
||||
|
||||
app.notify = capture_notify # ty: ignore
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_upgrade",
|
||||
new=AsyncMock(return_value=(True, "Updated deepagents-code")),
|
||||
),
|
||||
# Override the autouse `None` patch.
|
||||
patch(
|
||||
"deepagents_code.update_check.detect_shadowed_dcode",
|
||||
return_value=shadow,
|
||||
),
|
||||
):
|
||||
await app._dispatch_notification_action(entry.key, ActionId.INSTALL)
|
||||
await pilot.pause()
|
||||
|
||||
assert isinstance(app.screen, UpdateProgressScreen)
|
||||
status = app.screen.query(Static).filter(".up-status").first()
|
||||
assert "Update complete" not in str(status.render())
|
||||
assert "/opt/stale/bin/dcode" in str(status.render())
|
||||
assert "/home/user/.local/bin/dcode" in str(status.render())
|
||||
await pilot.press("c")
|
||||
await pilot.pause()
|
||||
|
||||
# The entry is still cleared — the upgrade did succeed; only the
|
||||
# post-restart guidance is different.
|
||||
assert app._notice_registry.get("update:available") is None
|
||||
assert copied == [format_shadowed_dcode_fix_command(shadow)]
|
||||
# The toast must NOT congratulate the user on a working upgrade.
|
||||
assert not any(
|
||||
"Updated to v" in m and "Quit and relaunch" in m for m in notified
|
||||
)
|
||||
# The warning toast names both paths so the user can act on it.
|
||||
assert any(
|
||||
"/opt/stale/bin/dcode" in m and "/home/user/.local/bin" in m
|
||||
for m in notified
|
||||
)
|
||||
|
||||
async def test_install_success_with_shadow_toast_only_when_modal_hidden(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A shadow with no progress modal still reaches the user as a toast.
|
||||
|
||||
When a modal is already on screen the install runs without pushing the
|
||||
`UpdateProgressScreen`, so `progress_modal_visible` is `False` and
|
||||
`mark_warning` is skipped. The shadow warning must still fire as a
|
||||
`notify(severity="warning")` toast — this is the less-common leg of the
|
||||
shadow branch, and the exact "user silently keeps the old version"
|
||||
failure mode this PR exists to prevent. A regression that nested the
|
||||
`self.notify(warning, ...)` inside `if progress_modal_visible:` would
|
||||
drop the warning entirely here and pass the modal-visible test.
|
||||
"""
|
||||
from textual.screen import ModalScreen
|
||||
|
||||
from deepagents_code.notifications import ActionId
|
||||
from deepagents_code.update_check import ShadowedDcode
|
||||
from deepagents_code.widgets.update_progress import UpdateProgressScreen
|
||||
|
||||
shadow = ShadowedDcode(
|
||||
shadowing_bin=Path("/opt/stale/bin/dcode"),
|
||||
upgraded_bin_dir=Path("/home/user/.local/bin"),
|
||||
)
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
|
||||
entry = _update_entry()
|
||||
app._notice_registry.add(entry)
|
||||
|
||||
notified: list[str] = []
|
||||
original_notify = app.notify
|
||||
|
||||
def capture_notify(message: str, **kwargs: Any) -> None:
|
||||
notified.append(message)
|
||||
original_notify(message, **kwargs)
|
||||
|
||||
monkeypatch.setattr(app, "notify", capture_notify)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
# A modal already owns the screen, so the install path takes the
|
||||
# toast-only branch (`progress_modal_visible` is False).
|
||||
await app.push_screen(ModalScreen())
|
||||
await pilot.pause()
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_upgrade",
|
||||
new=AsyncMock(return_value=(True, "Updated deepagents-code")),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.detect_shadowed_dcode",
|
||||
return_value=shadow,
|
||||
),
|
||||
):
|
||||
await app._dispatch_notification_action(entry.key, ActionId.INSTALL)
|
||||
await pilot.pause()
|
||||
|
||||
# The progress modal was never pushed, so the warning could only
|
||||
# have reached the user as a toast.
|
||||
assert not isinstance(app.screen, UpdateProgressScreen)
|
||||
|
||||
assert app._notice_registry.get("update:available") is None
|
||||
# The success line must not appear — relaunch would keep the old binary.
|
||||
assert not any(
|
||||
"Updated to v" in m and "Quit and relaunch" in m for m in notified
|
||||
)
|
||||
# The warning toast names both paths so the user can act on it.
|
||||
assert any(
|
||||
"/opt/stale/bin/dcode" in m and "/home/user/.local/bin" in m
|
||||
for m in notified
|
||||
)
|
||||
|
||||
async def test_debug_update_install_does_not_run_upgrade(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -10396,7 +10561,7 @@ class TestNotificationCenterIntegration:
|
||||
spinner = app.screen.query(Static).filter(".up-spinner").first()
|
||||
assert "Update failed. Try manually:" in str(status.render())
|
||||
assert details.display is True
|
||||
assert str(spinner.render()) == get_glyphs().checkmark
|
||||
assert str(spinner.render()) == get_glyphs().error
|
||||
|
||||
async def test_update_skip_once_clears_notified_marker(self) -> None:
|
||||
"""'Remind me next launch' calls clear_update_notified and removes the entry."""
|
||||
|
||||
@@ -47,6 +47,31 @@ class TestStartupAutoUpdate:
|
||||
):
|
||||
yield
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_shadowed_dcode(self) -> Iterator[None]:
|
||||
"""Default to "no PATH shadow detected" for the success-path tests.
|
||||
|
||||
Without this, every successful-upgrade test would run the real
|
||||
`detect_shadowed_dcode` against the host filesystem. That's
|
||||
hermetic only by accident — the test runner's editable install
|
||||
currently short-circuits at `detect_install_method() != "uv"` — but
|
||||
a uv-tool-managed Python or CI image that does match would silently
|
||||
re-route every "successful update" test through the new
|
||||
`if shadow is not None: return` branch and skip the restart
|
||||
assertion. Pin to `None` here so the contract being tested is
|
||||
"shadow path is opt-in"; the dedicated shadow-present test below
|
||||
patches it explicitly.
|
||||
|
||||
Patches at the source module rather than `deepagents_code.main`
|
||||
because `_run_startup_auto_update` lazy-imports it inside the
|
||||
function.
|
||||
"""
|
||||
with patch(
|
||||
"deepagents_code.update_check.detect_shadowed_dcode",
|
||||
return_value=None,
|
||||
):
|
||||
yield
|
||||
|
||||
def test_successful_update_restarts_before_launch(self) -> None:
|
||||
"""A successful startup auto-update should exec a fresh process."""
|
||||
console = MagicMock()
|
||||
@@ -86,6 +111,77 @@ class TestStartupAutoUpdate:
|
||||
upgrade.assert_awaited_once()
|
||||
restart.assert_called_once_with()
|
||||
|
||||
def test_successful_update_skips_restart_when_shadowed(self) -> None:
|
||||
"""Successful upgrade + shadowed dcode must NOT restart into the old binary.
|
||||
|
||||
Regression guard for the critical bug: when a stale `dcode` is
|
||||
earlier on PATH than uv's bin dir, re-exec'ing would silently
|
||||
re-launch the old version. The pre-launch path must surface a
|
||||
warning and return *before* `_restart_current_process` so the user
|
||||
sees the message and isn't stranded on the old in-memory version
|
||||
with no explanation. Also pins the markup-escape behavior: a path
|
||||
containing a Rich-special character must not raise.
|
||||
"""
|
||||
from deepagents_code.update_check import ShadowedDcode
|
||||
|
||||
console = MagicMock()
|
||||
upgrade = AsyncMock(return_value=(True, "updated"))
|
||||
# Embed `[` in the shadowing path — legal on POSIX filesystems —
|
||||
# so a regression that dropped `escape()` would raise a Rich
|
||||
# `MarkupError` here instead of silently emitting broken styling.
|
||||
shadow = ShadowedDcode(
|
||||
shadowing_bin=Path("/opt/old [legacy]/bin/dcode"),
|
||||
upgraded_bin_dir=Path("/home/user/.local/bin"),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("deepagents_code.config._is_editable_install", return_value=False),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_check_enabled",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_auto_update_enabled",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.get_cached_update_available",
|
||||
return_value=(True, "9.9.9"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.format_release_age_parenthetical",
|
||||
return_value="",
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.create_update_log_path",
|
||||
return_value=Path("/tmp/dcode-update.log"),
|
||||
),
|
||||
patch("deepagents_code.update_check.perform_upgrade", upgrade),
|
||||
# Override the autouse `_no_shadowed_dcode` fixture for this
|
||||
# single test by re-patching the same name with the positive
|
||||
# case. The innermost patch wins, so the autouse fixture's
|
||||
# `None` doesn't leak through.
|
||||
patch(
|
||||
"deepagents_code.update_check.detect_shadowed_dcode",
|
||||
return_value=shadow,
|
||||
),
|
||||
patch("deepagents_code.main._restart_current_process") as restart,
|
||||
):
|
||||
_run_startup_auto_update(console)
|
||||
|
||||
upgrade.assert_awaited_once()
|
||||
restart.assert_not_called()
|
||||
printed = " ".join(str(c.args[0]) for c in console.print.call_args_list)
|
||||
assert "Warning:" in printed
|
||||
# The path's `[legacy]` segment must be Rich-escaped (`\[legacy]`)
|
||||
# before interpolation under `markup=True`; a regression that
|
||||
# dropped `escape()` would either raise `MarkupError` (test fails)
|
||||
# or render `[legacy]` as a (broken) style tag. Asserting the
|
||||
# escaped form pins the fix.
|
||||
assert "/opt/old \\[legacy]/bin/dcode" in printed
|
||||
assert "/home/user/.local/bin" in printed
|
||||
assert "Continuing with v" in printed
|
||||
|
||||
def test_disabled_update_does_not_check_pypi(self) -> None:
|
||||
"""Disabled auto-update should not perform network or install work."""
|
||||
console = MagicMock()
|
||||
@@ -668,6 +764,15 @@ class TestStartupAutoUpdate:
|
||||
class TestAutoUpdateDefaultMigration:
|
||||
"""First-run consent/migration notice for the auto-update opt-out default."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_shadowed_dcode(self) -> Iterator[None]:
|
||||
"""Default to no PATH shadow — same reasoning as `TestStartupAutoUpdate`."""
|
||||
with patch(
|
||||
"deepagents_code.update_check.detect_shadowed_dcode",
|
||||
return_value=None,
|
||||
):
|
||||
yield
|
||||
|
||||
def test_first_run_announces_and_skips_install(self) -> None:
|
||||
"""An implicit (default) opt-in announces once and skips the install."""
|
||||
console = MagicMock()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -117,3 +117,107 @@ async def test_update_progress_screen_close_waits_until_done(tmp_path) -> None:
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert app.screen is app.screen_stack[0]
|
||||
|
||||
|
||||
async def test_update_progress_screen_failure_uses_error_glyph(tmp_path) -> None:
|
||||
"""Failure completion should not look visually successful."""
|
||||
screen = UpdateProgressScreen(
|
||||
latest="2.0.0",
|
||||
command="uv tool install -U deepagents-code",
|
||||
log_path=tmp_path / "update.log",
|
||||
)
|
||||
|
||||
app = App()
|
||||
async with app.run_test() as pilot:
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
|
||||
screen.mark_failure("uv tool install -U deepagents-code")
|
||||
await pilot.pause()
|
||||
|
||||
status = screen.query(Static).filter(".up-status").first()
|
||||
spinner = screen.query(Static).filter(".up-spinner").first()
|
||||
assert "Update failed" in str(status.render())
|
||||
assert str(spinner.render()) == get_glyphs().error
|
||||
|
||||
|
||||
async def test_update_progress_screen_warning_stays_visible(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Warning completion keeps the user action visible in the modal."""
|
||||
screen = UpdateProgressScreen(
|
||||
latest="2.0.0",
|
||||
command="uv tool upgrade deepagents-code",
|
||||
log_path=tmp_path / "update.log",
|
||||
)
|
||||
|
||||
copied: list[str] = []
|
||||
app = App()
|
||||
monkeypatch.setattr(app, "copy_to_clipboard", copied.append)
|
||||
async with app.run_test() as pilot:
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
|
||||
fix_command = "export PATH=/home/user/.local/bin:$PATH\nhash -r"
|
||||
screen.mark_warning(
|
||||
"Update installed, but another `dcode` is earlier on your PATH.\n"
|
||||
"Remove the shadowing binary, or put /home/user/.local/bin earlier "
|
||||
"on your PATH, then relaunch.",
|
||||
copy_text=fix_command,
|
||||
)
|
||||
await pilot.pause()
|
||||
|
||||
status = screen.query(Static).filter(".up-status").first()
|
||||
details = screen.query(Static).filter(".up-details").first()
|
||||
spinner = screen.query(Static).filter(".up-spinner").first()
|
||||
help_text = screen.query(Static).filter(".up-help").first()
|
||||
assert "Update complete" not in str(status.render())
|
||||
assert "another `dcode` is earlier on your PATH" in str(status.render())
|
||||
assert "Remove the shadowing binary" in str(status.render())
|
||||
assert details.display is True
|
||||
assert str(spinner.render()) == get_glyphs().warning
|
||||
assert "c copy fix command" in str(help_text.render())
|
||||
|
||||
await pilot.press("c")
|
||||
await pilot.pause()
|
||||
|
||||
assert copied == [fix_command]
|
||||
|
||||
|
||||
async def test_update_progress_screen_warning_without_copy_text_copies_log_path(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""`mark_warning` without `copy_text` falls back to copying the log path.
|
||||
|
||||
The production call site always supplies `copy_text`, but the parameter is
|
||||
optional, so this pins the documented fallback: with no fix command the
|
||||
help line reverts to "c copy log path" and `c` copies the log path rather
|
||||
than silently doing nothing.
|
||||
"""
|
||||
log_path = tmp_path / "update.log"
|
||||
screen = UpdateProgressScreen(
|
||||
latest="2.0.0",
|
||||
command="uv tool install -U deepagents-code",
|
||||
log_path=log_path,
|
||||
)
|
||||
|
||||
copied: list[str] = []
|
||||
app = App()
|
||||
monkeypatch.setattr(app, "copy_to_clipboard", copied.append)
|
||||
async with app.run_test() as pilot:
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
|
||||
screen.mark_warning("Another `dcode` is earlier on your PATH.")
|
||||
await pilot.pause()
|
||||
|
||||
spinner = screen.query(Static).filter(".up-spinner").first()
|
||||
help_text = screen.query(Static).filter(".up-help").first()
|
||||
assert str(spinner.render()) == get_glyphs().warning
|
||||
assert "c copy log path" in str(help_text.render())
|
||||
assert "c copy fix command" not in str(help_text.render())
|
||||
|
||||
await pilot.press("c")
|
||||
await pilot.pause()
|
||||
|
||||
assert copied == [str(log_path)]
|
||||
|
||||
@@ -41,6 +41,17 @@ def _block_sdk_pypi_fetch(tmp_path: Path) -> Iterator[None]:
|
||||
"deepagents_code.update_check.is_update_available",
|
||||
return_value=(False, None),
|
||||
),
|
||||
# Pin the post-upgrade shadow check to a clean "no shadow" for the
|
||||
# whole module. Several `/update` tests pin `detect_install_method`
|
||||
# to `"uv"` to exercise pre-release handling, which would otherwise
|
||||
# make the real `detect_shadowed_dcode` run against the test
|
||||
# runner's filesystem and replace the expected success message
|
||||
# with the shadow warning. The dedicated shadow-present tests in
|
||||
# this file override this patch with their own.
|
||||
patch(
|
||||
"deepagents_code.update_check.detect_shadowed_dcode",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
@@ -542,6 +553,67 @@ async def test_update_slash_command_prerelease_updates_channel() -> None:
|
||||
assert "Updated to v99.0.0rc1" in str(app_msgs[-1]._content)
|
||||
|
||||
|
||||
async def test_update_slash_command_replaces_success_with_shadow_warning() -> None:
|
||||
"""A shadowed `dcode` after a successful upgrade swaps success line for warning.
|
||||
|
||||
Regression guard for the inverted-conditional bug class: showing the
|
||||
user a green "relaunch to use the new version" line and then *also*
|
||||
warning that relaunching will keep the old version is the exact UX
|
||||
this branch exists to prevent. The shadow path must mount an
|
||||
`ErrorMessage` with the warning and skip the success `AppMessage`
|
||||
entirely; a regression that flipped the `if/else` (or kept the
|
||||
success line unconditionally) would ship a reassuring success line
|
||||
over a broken upgrade.
|
||||
"""
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.update_check import ShadowedDcode
|
||||
from deepagents_code.widgets.messages import AppMessage, ErrorMessage
|
||||
|
||||
shadow = ShadowedDcode(
|
||||
shadowing_bin=Path("/opt/stale/bin/dcode"),
|
||||
upgraded_bin_dir=Path("/home/user/.local/bin"),
|
||||
)
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.config._is_editable_install",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.is_update_available",
|
||||
return_value=(True, "99.0.0"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.update_check.perform_upgrade",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(True, ""),
|
||||
),
|
||||
# Override the module-level autouse `None` patch with the
|
||||
# positive case. Innermost patch wins.
|
||||
patch(
|
||||
"deepagents_code.update_check.detect_shadowed_dcode",
|
||||
return_value=shadow,
|
||||
),
|
||||
):
|
||||
await app._handle_command("/update")
|
||||
await pilot.pause()
|
||||
|
||||
plain_msgs = [
|
||||
str(m._content) for m in app.query(AppMessage) if not m._is_markdown
|
||||
]
|
||||
# The shadow path must NOT mount the success line, because relaunching
|
||||
# would not actually use the new version. A regression that kept the
|
||||
# success line would show both messages, contradicting itself.
|
||||
assert not any("Updated to v99.0.0" in m for m in plain_msgs)
|
||||
# The warning is mounted as an `ErrorMessage` (red), not a generic
|
||||
# `AppMessage`, so it visually stands apart from neutral status text.
|
||||
error_msgs = [str(m._content) for m in app.query(ErrorMessage)]
|
||||
assert any("/opt/stale/bin/dcode" in m for m in error_msgs)
|
||||
assert any("/home/user/.local/bin" in m for m in error_msgs)
|
||||
|
||||
|
||||
async def test_update_slash_command_prerelease_unsupported_install_refuses() -> None:
|
||||
"""`/update --prerelease` refuses on a non-uv install before hitting PyPI."""
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
|
||||
Reference in New Issue
Block a user