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:
Mason Daugherty
2026-06-23 19:55:43 -04:00
committed by GitHub
parent 2a30c0618c
commit 8ca0a185a1
9 changed files with 1900 additions and 73 deletions
+55 -14
View File
@@ -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",
+27
View File
@@ -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,
+447 -31
View File
@@ -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)
+166 -1
View File
@@ -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."""
+105
View File
@@ -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