feat(code): allow dependency updates without requiring release (#4157)

`/update --deps` (and a prompt on `/update` when already current)
refreshes dependencies to their newest in-range versions without waiting
for a new release.

---

Dependencies are range-pinned, so newer in-range releases (e.g. a fresh
`langchain-openai`) are already permitted by a published
`deepagents-code` — but `/update` only ever checked the dcode version on
PyPI and dead-ended at "Already on the latest version", so users had to
drop to raw `uv` to pull them in.

This makes the refresh first-class in the TUI:
- `/update --deps` re-resolves the tool environment via `uv tool install
-U deepagents-code==<current>` (`perform_dependency_refresh`), so
compatible dependency releases are picked up without crossing to a newer
app version. If a dcode update also happens to be available, `/update
--deps` first asks (via `UpdateBeforeDependenciesConfirmScreen`) whether
to take the app update instead; declining continues with the deps-only
refresh.
- Plain `/update`, when dcode is already current, computes a preflight
dry-run plan first and only then offers the refresh through a
confirmation modal (`RefreshDependenciesConfirmScreen`, which previews
the planned changes) instead of dead-ending.
- Both paths parse uv's environment diff and report exactly which
dependencies moved; a normal version bump also surfaces any dependency
changes that rode along.

Editable (and other non-uv) installs are still refused, and a dep
refresh stays explicit/confirmed (it is not folded into silent
background auto-update), consistent with the "announce before running
shell" rule. `uv tool install`/`upgrade` has no `--dry-run`, so the
preflight plan is computed against the running tool environment with `uv
pip install --dry-run --python <sys.executable>`
(`perform_dependency_refresh_dry_run`), using the same pinned
requirement, extras, and preserved `--with` packages as the real
refresh.

Made by [Open
SWE](https://openswe.vercel.app/agents/d64c7415-11cc-947f-f5f3-67aca0ec21ed)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-06-23 01:16:24 -04:00
committed by GitHub
parent e303ce986a
commit 7beb97a2b0
7 changed files with 2190 additions and 18 deletions
+282 -4
View File
@@ -3873,8 +3873,10 @@ class DeepAgentsApp(App):
async def _handle_update_command(self, command: str = "/update") -> None:
"""Handle the `/update` slash command — check for and install updates.
Parses an optional `--prerelease` flag from the raw command line; any
other option is rejected with a usage message.
Parses optional `--prerelease` and `--deps` flags from the raw command
line; any other option is rejected with a usage message. `--deps`
re-resolves dependencies to their newest in-range versions even when
`deepagents-code` itself is already current.
Args:
command: The raw slash-command line as typed, including any options.
@@ -3885,16 +3887,18 @@ class DeepAgentsApp(App):
# loudly instead of silently downgrading to a stable update — mirroring
# the headless path, which also refuses unknown options rather than
# silently ignoring them.
unknown = [opt for opt in parts[1:] if opt != "--prerelease"]
allowed_options = {"--prerelease", "--deps"}
unknown = [opt for opt in parts[1:] if opt not in allowed_options]
if unknown:
await self._mount_message(
AppMessage(
f"Unknown option(s) for /update: {' '.join(unknown)}. "
"Usage: /update [--prerelease]",
"Usage: /update [--deps] [--prerelease]",
),
)
return
prerelease_requested = "--prerelease" in parts[1:]
deps_only = "--deps" in parts[1:]
include_prereleases = True if prerelease_requested else None
try:
from deepagents_code._env_vars import DEBUG_UPDATE
@@ -3902,10 +3906,14 @@ class DeepAgentsApp(App):
from deepagents_code.config import _is_editable_install
from deepagents_code.update_check import (
_PRERELEASE_UNSUPPORTED_MESSAGE,
dependency_refresh_supported,
format_age_suffix,
format_dependency_changes,
format_installed_age_suffix,
format_release_age_parenthetical,
is_update_available,
parse_dependency_changes,
perform_dependency_refresh_dry_run,
perform_upgrade,
prerelease_upgrade_supported,
upgrade_command,
@@ -3948,14 +3956,72 @@ class DeepAgentsApp(App):
)
return
if not available:
if deps_only:
await self._refresh_dependencies(
include_prereleases=include_prereleases,
)
return
age_suffix = await asyncio.to_thread(format_age_suffix, cli_version)
await self._mount_message(
AppMessage(
f"Already on the latest version (v{cli_version}{age_suffix}).",
),
)
# dcode is current, but its dependencies may have newer in-range
# releases. Compute a dry-run plan first so the confirmation only
# appears when there are concrete updates to apply. Keep the support
# gate before the check so brew/other users aren't asked about an
# action that cannot run for their install.
refresh_supported, _reason = await asyncio.to_thread(
dependency_refresh_supported,
)
if not refresh_supported:
return
await self._mount_message(
AppMessage("Checking for dependency updates...")
)
success, output = await perform_dependency_refresh_dry_run(
include_prereleases=include_prereleases,
)
if not success:
detail = f": {output[:200]}" if output else ""
await self._mount_message(
AppMessage(f"Could not check dependency updates{detail}"),
)
return
dep_changes = parse_dependency_changes(output)
if not dep_changes:
await self._mount_message(
AppMessage("Dependencies are already up to date."),
)
return
planned = format_dependency_changes(dep_changes)
if await self._confirm_refresh_dependencies(planned_changes=planned):
await self._refresh_dependencies(
include_prereleases=include_prereleases,
)
else:
await self._mount_message(AppMessage("Dependency refresh skipped."))
return
if deps_only:
refresh_supported, _reason = await asyncio.to_thread(
dependency_refresh_supported,
)
if (
refresh_supported
and not await self._confirm_update_before_dependency_refresh(
current=cli_version,
latest=latest,
)
):
await self._refresh_dependencies(
include_prereleases=include_prereleases,
app_update_version=latest,
)
return
release_age = await asyncio.to_thread(
format_release_age_parenthetical,
latest,
@@ -3988,6 +4054,20 @@ class DeepAgentsApp(App):
"not the CLI)."
),
)
# The upgrade re-resolves the whole environment, so surface any
# dependency bumps that rode along with the dcode release.
dep_changes = [
change
for change in parse_dependency_changes(output)
if change.name != "deepagents-code"
]
if dep_changes:
await self._mount_message(
AppMessage(
"Dependencies updated:\n"
f"{format_dependency_changes(dep_changes)}",
),
)
else:
cmd = upgrade_command(include_prereleases=include_prereleases)
detail = f": {output[:200]}" if output else ""
@@ -4000,6 +4080,204 @@ class DeepAgentsApp(App):
ErrorMessage(f"Update failed: {type(exc).__name__}: {exc}"),
)
async def _refresh_dependencies(
self,
*,
include_prereleases: bool | None,
app_update_version: str | None = None,
) -> None:
"""Re-resolve dependencies to their newest in-range versions.
Reinstalls the current `deepagents-code` version with an upgraded
dependency resolution, then reports which dependencies actually moved.
Used by the `/update --deps` and already-current refresh flows. Editable
installs are rejected by the caller before this runs; the refresh is
uv-only by construction (other install methods are refused by
`perform_dependency_refresh`), so pre-release resolution is always
available here.
Args:
include_prereleases: Whether to include alpha/beta/rc releases;
`None` follows the installed version's channel.
app_update_version: Newer `deepagents-code` version discovered by
the caller, if dependency refresh is intentionally staying on
the current app version.
"""
from deepagents_code._env_vars import DEBUG_UPDATE
from deepagents_code.update_check import (
format_dependency_changes,
parse_dependency_changes,
perform_dependency_refresh,
)
await self._mount_message(AppMessage("Refreshing dependencies..."))
if os.environ.get(DEBUG_UPDATE):
await self._mount_message(
AppMessage("Skipped dependency refresh (debug mode)."),
)
return
success, output = await perform_dependency_refresh(
include_prereleases=include_prereleases,
)
if not success:
# Lead with the start of the output for parity with the upgrade
# failure path; uv prints the actionable summary (e.g. "No solution
# found") first. The full output is persisted to the update log.
detail = f": {output[:200]}" if output else ""
await self._mount_message(
AppMessage(
f"Dependency refresh failed{detail}",
),
)
return
changes = parse_dependency_changes(output)
if output.strip() and not changes:
# The refresh succeeded but nothing parsed out of uv's diff. Either
# nothing moved, or uv's output format drifted past our parser. Leave
# a breadcrumb so the latter doesn't masquerade as "up to date"
# without a trace (the raw output is retained in the update log).
logger.warning(
"Dependency refresh produced no parseable changes; uv output "
"format may have drifted.",
)
self_changes = [
change for change in changes if change.name == "deepagents-code"
]
dep_changes = [change for change in changes if change.name != "deepagents-code"]
if not dep_changes and not self_changes:
if app_update_version is not None:
await self._mount_message(
AppMessage(
"Dependencies are already up to date. "
"A deepagents-code update is available: "
f"v{app_update_version}.",
),
)
return
await self._mount_message(
AppMessage("Dependencies are already up to date."),
)
return
message_parts: list[str] = []
if self_changes:
message_parts.append(
f"Updated deepagents-code:\n{format_dependency_changes(self_changes)}"
)
if dep_changes:
message_parts.append(
f"Refreshed dependencies:\n{format_dependency_changes(dep_changes)}"
)
if app_update_version is not None:
message_parts.append(
f"A deepagents-code update is available: v{app_update_version}."
)
await self._mount_message(
AppMessage(
"\n".join(message_parts) + "\nQuit and relaunch dcode to use them.",
),
)
async def _confirm_update_before_dependency_refresh(
self,
*,
current: str,
latest: str,
) -> bool:
"""Ask whether `/update --deps` should take an app update first.
Args:
current: Currently running `deepagents-code` version.
latest: Latest available `deepagents-code` version.
Returns:
`True` only when the user explicitly chooses the app update; `False`
on cancel, timeout, or mount failure so `/update --deps` continues
with the requested dependency refresh.
"""
from deepagents_code.widgets.update_confirm import (
UpdateBeforeDependenciesConfirmScreen,
)
try:
confirmed = await asyncio.wait_for(
self._push_screen_wait(
UpdateBeforeDependenciesConfirmScreen(
current=current,
latest=latest,
)
),
timeout=_MODAL_WATCHDOG_TIMEOUT_SECONDS,
)
except TimeoutError:
logger.warning(
"App-update confirmation timed out; continuing dependency refresh",
)
await self._mount_message(
AppMessage(
"Update prompt timed out; refreshing dependencies for the "
"current version instead.",
),
)
return False
except Exception:
logger.exception("Failed to mount app-update confirmation")
await self._mount_message(
AppMessage(
"Couldn't show the update prompt; refreshing dependencies "
"for the current version instead.",
),
)
return False
return confirmed is True
async def _confirm_refresh_dependencies(
self,
*,
planned_changes: str | None = None,
) -> bool:
"""Ask the user to confirm a dependency refresh via a modal.
A watchdog bounds the wait so a modal that never resolves can't wedge
command handling; a timeout or mount failure is treated as a cancel.
Args:
planned_changes: Optional preflight summary to show before confirming.
Returns:
`True` only when the user explicitly confirmed; `False` on cancel,
timeout, or mount failure.
"""
from deepagents_code.widgets.update_confirm import (
RefreshDependenciesConfirmScreen,
)
try:
confirmed = await asyncio.wait_for(
self._push_screen_wait(
RefreshDependenciesConfirmScreen(planned_changes=planned_changes),
),
timeout=_MODAL_WATCHDOG_TIMEOUT_SECONDS,
)
except TimeoutError:
logger.warning(
"Dependency-refresh confirmation timed out; treating as cancel",
)
await self._mount_message(
AppMessage("Dependency-refresh prompt timed out; skipping."),
)
return False
except Exception:
logger.exception("Failed to mount dependency-refresh confirmation")
await self._mount_message(
AppMessage(
"Couldn't show the dependency-refresh prompt; skipping. "
"See logs for details.",
),
)
return False
return confirmed is True
async def _handle_install_command(self, command: str) -> None:
"""Handle the `/install <extra>` slash command.
@@ -189,7 +189,8 @@ COMMANDS: tuple[SlashCommand, ...] = (
name="/update",
description="Check for and install updates",
bypass_tier=BypassTier.QUEUED,
hidden_keywords="upgrade",
hidden_keywords="upgrade dependencies deps refresh",
argument_hint="[--deps] [--prerelease]",
),
SlashCommand(
name="/install",
+487 -12
View File
@@ -25,16 +25,15 @@ import tomllib
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
from contextlib import suppress
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Literal, TextIO
from pathlib import Path
from typing import Any, Literal, NamedTuple, TextIO
from packaging.utils import canonicalize_name
from packaging.version import InvalidVersion, Version
from deepagents_code._version import PYPI_URL, SDK_PYPI_URL, USER_AGENT, __version__
from deepagents_code.model_config import DEFAULT_CONFIG_PATH, DEFAULT_STATE_DIR
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
CACHE_FILE: Path = DEFAULT_STATE_DIR / "latest_version.json"
@@ -843,6 +842,46 @@ def prerelease_upgrade_supported(
return True, None
_DEPENDENCY_REFRESH_UNSUPPORTED: dict[InstallMethod, str] = {
"unknown": "Editable install detected — skipping dependency refresh.",
"brew": (
"Homebrew install detected — dependency-only refresh is not "
"supported without upgrading deepagents-code."
),
"other": (
"Unsupported install method detected — cannot refresh dependencies "
"without knowing which environment provides `dcode`."
),
}
"""Why each non-uv install method can't do a dependency-only refresh."""
def dependency_refresh_supported(
method: InstallMethod | None = None,
) -> tuple[bool, str | None]:
"""Return whether a dependency-only refresh is possible for the install.
A dependency refresh reinstalls the *current* `deepagents-code` version with
upgraded dependency resolution (`uv tool install -U deepagents-code==<v>`).
Only uv-managed installs can express that without crossing to a newer app
version, so callers should refuse before prompting or shelling out. This is
the single source of truth for both the gate in the TUI and the refusal in
`perform_dependency_refresh`.
Args:
method: Install method override. Auto-detected if `None`.
Returns:
A `(supported, reason)` tuple. `reason` is `None` when supported, else a
user-facing explanation of why the refresh is refused.
"""
if method is None:
method = detect_install_method()
if method == "uv":
return True, None
return False, _DEPENDENCY_REFRESH_UNSUPPORTED[method]
def cleanup_update_logs(
*,
retention_days: int = UPDATE_LOG_RETENTION_DAYS,
@@ -1062,6 +1101,214 @@ async def perform_upgrade(
return await _run_install_subprocess(cmd, progress=progress, log_path=log_path)
async def perform_dependency_refresh(
*,
progress: UpgradeProgressCallback | None = None,
log_path: Path | None = None,
include_prereleases: bool | None = None,
) -> tuple[bool, str]:
"""Refresh dependencies while keeping `deepagents-code` on this version.
Runs `uv tool install -U deepagents-code==<current>` instead of
`uv tool upgrade deepagents-code`, so compatible dependency releases can be
picked up without crossing to a newer app version. Only uv-managed installs
are supported; other install methods cannot safely express this operation.
Args:
progress: Optional callback invoked for each output line.
log_path: Optional path to persist command output.
include_prereleases: Whether to include alpha/beta/rc releases. When
`None`, follows the installed version's channel.
Returns:
`(success, output)` — *output* is the combined stdout/stderr, or an
explanatory message when the install method is unsupported, `uv` is
unavailable, requirement introspection fails, or the subprocess
fails or times out.
"""
supported, reason = dependency_refresh_supported()
if not supported:
return False, reason or ""
if not shutil.which("uv"):
return False, "`uv` not found on PATH."
from deepagents_code.extras_info import ExtrasIntrospectionError
try:
cmd = dependency_refresh_command(
include_prereleases=include_prereleases,
)
except (
ExtrasIntrospectionError,
ToolRequirementIntrospectionError,
ValueError,
) as exc:
return False, f"{type(exc).__name__}: {exc}"
return await _run_install_subprocess(cmd, progress=progress, log_path=log_path)
async def perform_dependency_refresh_dry_run(
*,
progress: UpgradeProgressCallback | None = None,
log_path: Path | None = None,
include_prereleases: bool | None = None,
) -> tuple[bool, str]:
"""Resolve a dependency refresh plan without mutating the tool environment.
`uv tool install` has no `--dry-run`, so this targets the running tool
environment with `uv pip install --dry-run --python <sys.executable>`. It
uses the same pinned `deepagents-code` requirement, installed extras, and
preserved `--with` packages as the real refresh command.
Args:
progress: Optional callback invoked for each output line.
log_path: Optional path to persist command output.
include_prereleases: Whether to include alpha/beta/rc releases. When
`None`, follows the installed version's channel.
Returns:
`(success, output)` — *output* is the combined stdout/stderr from uv, or
an explanatory message when the plan cannot be computed safely.
"""
supported, reason = dependency_refresh_supported()
if not supported:
return False, reason or ""
if not shutil.which("uv"):
return False, "`uv` not found on PATH."
from deepagents_code.extras_info import ExtrasIntrospectionError
try:
cmd = dependency_refresh_dry_run_command(
include_prereleases=include_prereleases,
)
except (
ExtrasIntrospectionError,
ToolRequirementIntrospectionError,
ValueError,
) as exc:
return False, f"{type(exc).__name__}: {exc}"
return await _run_install_subprocess(cmd, progress=progress, log_path=log_path)
class DependencyChange(NamedTuple):
"""A single package version change parsed from uv's environment-diff output.
Emitted by both `uv tool upgrade` and `uv tool install -U` (the
dependency-refresh path), so the wording stays command-agnostic. `old` is
`None` for a newly added package and `new` is `None` for a removed one; both
are set for an in-place version bump. The `(None, None)` state is invalid —
see `kind`.
"""
name: str
"""Package name exactly as uv reported it in the diff line."""
old: str | None
"""Version before the change; `None` when the package was newly added."""
new: str | None
"""Version after the change; `None` when the package was removed."""
@property
def kind(self) -> Literal["added", "removed", "bumped"]:
"""Classify the change from which version sides are present.
Reading the case from here keeps consumers (e.g.
`format_dependency_changes`) from re-deriving it via field truthiness,
and turns the meaningless `(None, None)` shape into a hard error instead
of silently rendering as a removal.
Returns:
`"added"` when only `new` is set, `"removed"` when only `old` is set,
and `"bumped"` when both are set.
Raises:
ValueError: If neither `old` nor `new` is set.
"""
if self.old is None and self.new is None:
msg = (
f"DependencyChange {self.name!r} records neither an old nor new version"
)
raise ValueError(msg)
if self.old is None:
return "added"
if self.new is None:
return "removed"
return "bumped"
_DEP_CHANGE_RE = re.compile(
r"^\s*([+-])\s+([A-Za-z0-9._-]+)==([^\s(]+)(?:\s+\(.*\))?\s*$"
)
"""Matches uv's environment-diff lines, e.g. ` - langchain-openai==1.3.2`.
The optional trailing group tolerates uv's source annotations for non-PyPI
packages, e.g. ` + example==0.1.0 (from file:///path)`.
"""
def parse_dependency_changes(output: str) -> list[DependencyChange]:
"""Parse package version changes from uv's environment-diff output.
uv reports environment changes as paired ` - pkg==old` / ` + pkg==new`
lines; this collapses them into one `DependencyChange` per package,
preserving first-seen order.
Args:
output: Combined stdout/stderr from a `uv tool install`/`upgrade`
subprocess.
Returns:
One entry per package whose version was added, removed, or bumped.
"""
removed: dict[str, str] = {}
added: dict[str, str] = {}
order: list[str] = []
seen: set[str] = set()
for line in output.splitlines():
match = _DEP_CHANGE_RE.match(line)
if match is None:
continue
sign, name, version = match.group(1), match.group(2), match.group(3)
(removed if sign == "-" else added)[name] = version
if name not in seen:
seen.add(name)
order.append(name)
return [
DependencyChange(name=name, old=removed.get(name), new=added.get(name))
for name in order
]
def format_dependency_changes(changes: Sequence[DependencyChange]) -> str:
"""Render dependency changes as an aligned, human-readable block.
Args:
changes: Parsed changes from `parse_dependency_changes`.
Returns:
A newline-joined, column-aligned summary, or `""` when empty.
"""
if not changes:
return ""
width = max(len(change.name) for change in changes)
lines: list[str] = []
for change in changes:
name = change.name.ljust(width)
if change.kind == "bumped":
lines.append(f" {name} {change.old} -> {change.new}")
elif change.kind == "added":
lines.append(f" {name} {change.new} (new)")
else: # removed
lines.append(f" {name} {change.old} (removed)")
return "\n".join(lines)
class ToolRequirementIntrospectionError(RuntimeError):
"""Raised when uv tool requested requirements cannot be preserved."""
_EXTRA_NAME_RE = re.compile(r"^[A-Za-z0-9](?:[-_.A-Za-z0-9]*[A-Za-z0-9])?$")
"""Conservative package-extra name pattern used before shell command display."""
@@ -1094,14 +1341,142 @@ def is_valid_package_name(package: str) -> bool:
return bool(_PACKAGE_NAME_RE.fullmatch(package))
def _dcode_extras_requirement(extras: Iterable[str]) -> str:
def _uv_tool_receipt_path(tool_root: Path | None = None) -> Path:
"""Return the uv receipt path for the current tool environment.
Args:
tool_root: Optional uv tool environment root. Defaults to `sys.prefix`.
Returns:
The expected `uv-receipt.toml` path.
"""
return (tool_root or Path(sys.prefix)) / "uv-receipt.toml"
def _uv_tool_receipt_data(tool_root: Path | None = None) -> dict[str, Any]:
"""Return parsed uv tool receipt data for the current tool environment.
Args:
tool_root: Optional uv tool environment root. Defaults to `sys.prefix`.
Returns:
Parsed TOML data from `uv-receipt.toml`.
Raises:
ToolRequirementIntrospectionError: If the receipt cannot be read or
parsed.
"""
receipt_path = _uv_tool_receipt_path(tool_root)
try:
return tomllib.loads(receipt_path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
msg = f"uv tool receipt not found at {receipt_path}"
raise ToolRequirementIntrospectionError(msg) from exc
except (OSError, tomllib.TOMLDecodeError) as exc:
msg = f"Could not read uv tool receipt at {receipt_path}: {exc}"
raise ToolRequirementIntrospectionError(msg) from exc
def _uv_tool_python(tool_root: Path | None = None) -> str | None:
"""Return the Python interpreter recorded in the uv tool receipt.
Args:
tool_root: Optional uv tool environment root. Defaults to `sys.prefix`.
Returns:
The recorded `[tool].python` value, or `None` when the receipt does not
pin an interpreter.
Raises:
ToolRequirementIntrospectionError: If the receipt cannot be read, parsed,
or safely re-expressed as a `--python` value.
"""
data = _uv_tool_receipt_data(tool_root)
tool = data.get("tool")
if not isinstance(tool, dict):
msg = "uv tool receipt is missing `[tool]`"
raise ToolRequirementIntrospectionError(msg)
python = tool.get("python")
if python is None:
return None
if not isinstance(python, str) or not python:
msg = "uv tool receipt contains an invalid `[tool].python` value"
raise ToolRequirementIntrospectionError(msg)
return python
def _uv_tool_with_packages(
*,
distribution_name: str = "deepagents-code",
tool_root: Path | None = None,
) -> tuple[str, ...]:
"""Return package names recorded as uv tool `--with` requirements.
uv records the tool's requested requirements in `uv-receipt.toml`. Reading
that receipt preserves only packages the user asked uv to keep, avoiding the
over-broad fallback of promoting every installed transitive dependency to a
top-level `--with` requirement.
Args:
distribution_name: Main tool distribution to exclude from `--with`.
tool_root: Optional uv tool environment root. Defaults to `sys.prefix`.
Returns:
A sorted tuple of validated package names to pass as `--with` values.
Raises:
ToolRequirementIntrospectionError: If the receipt cannot be read, parsed,
or safely re-expressed as package-name `--with` requirements.
"""
data = _uv_tool_receipt_data(tool_root)
tool = data.get("tool")
requirements = tool.get("requirements") if isinstance(tool, dict) else None
if not isinstance(requirements, list):
msg = "uv tool receipt is missing `[tool].requirements`"
raise ToolRequirementIntrospectionError(msg)
main = canonicalize_name(distribution_name)
packages: set[str] = set()
for entry in requirements:
if not isinstance(entry, dict):
msg = "uv tool receipt contains a non-table requirement entry"
raise ToolRequirementIntrospectionError(msg)
name = entry.get("name")
if not isinstance(name, str) or not name:
msg = "uv tool receipt contains a requirement without a package name"
raise ToolRequirementIntrospectionError(msg)
if canonicalize_name(name) == main:
continue
unsupported_keys = sorted(set(entry) - {"name"})
if unsupported_keys:
msg = (
f"uv tool receipt requirement {name!r} cannot be preserved "
"automatically; reinstall it manually after refreshing "
"dependencies"
)
raise ToolRequirementIntrospectionError(msg)
if not is_valid_package_name(name):
msg = (
f"Invalid uv tool receipt package name {name!r}: must match "
f"PEP 508 ({_PACKAGE_NAME_RE.pattern})"
)
raise ToolRequirementIntrospectionError(msg)
packages.add(name)
return tuple(sorted(packages))
def _dcode_extras_requirement(
extras: Iterable[str],
*,
version: str | None = None,
) -> str:
"""Return the validated `deepagents-code[...]` requirement for a uv install.
Shared by the extra- and package-install commands so already-installed
extras survive a `uv tool install` reinstall — a bare `deepagents-code`
request would replace the tool and drop them. Returns plain
`deepagents-code` when no extras are selected; otherwise the single-quoted
bracket form, which keeps zsh from globbing the brackets.
`deepagents-code` when no extras or version are selected; otherwise the
shell-quoted requirement form, which keeps zsh from globbing brackets.
Args:
extras: Extra names to encode. Each is validated against PEP 508
@@ -1109,10 +1484,11 @@ def _dcode_extras_requirement(extras: Iterable[str]) -> str:
caller-supplied extras (`install_extras_command`) and a
redundant re-check for extras read from distribution metadata
(`install_package_command`).
version: Optional exact `deepagents-code` version pin.
Returns:
Shell-safe requirement token, e.g. `deepagents-code` or
`'deepagents-code[baseten,nvidia]'`.
`'deepagents-code[baseten,nvidia]==1.0.0'`.
Raises:
ValueError: If any extra fails PEP 508 validation.
@@ -1125,10 +1501,109 @@ def _dcode_extras_requirement(extras: Iterable[str]) -> str:
f"({_EXTRA_NAME_RE.pattern})"
)
raise ValueError(msg)
if not names:
return "deepagents-code"
extras_part = ",".join(names)
return f"'deepagents-code[{extras_part}]'"
version_suffix = ""
if version is not None:
try:
parsed = Version(version)
except InvalidVersion as exc:
msg = f"Invalid deepagents-code version {version!r}"
raise ValueError(msg) from exc
version_suffix = f"=={parsed}"
extras_part = f"[{','.join(names)}]" if names else ""
requirement = f"deepagents-code{extras_part}{version_suffix}"
if not names and version is None:
return requirement
return shlex.quote(requirement)
def dependency_refresh_command(
*,
version: str = __version__,
include_prereleases: bool | None = None,
distribution_name: str = "deepagents-code",
) -> str:
"""Return the uv command that refreshes deps for the current dcode version.
Args:
version: Exact `deepagents-code` version to keep installed.
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, 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
def dependency_refresh_dry_run_command(
*,
version: str = __version__,
include_prereleases: bool | None = None,
distribution_name: str = "deepagents-code",
python: str | None = None,
) -> str:
"""Return the uv command that plans a dependency refresh without installing.
Args:
version: Exact `deepagents-code` version to keep installed.
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 and uv receipt requirements.
python: Python executable for the target environment. Defaults to the
running interpreter, which is the current dcode tool environment.
Returns:
Shell command string suitable for execution via the shell.
Raises:
ToolRequirementIntrospectionError: If the target Python or uv tool receipt
requirements cannot be determined safely.
"""
from deepagents_code.extras_info import installed_extra_names
target_python = python or sys.executable
if not target_python:
msg = "Could not determine the running Python executable"
raise ToolRequirementIntrospectionError(msg)
extras = installed_extra_names(distribution_name, strict=True)
requirement = _dcode_extras_requirement(extras, version=version)
cmd = (
"uv pip install --dry-run --python "
f"{shlex.quote(target_python)} -U {requirement}"
)
with_packages = _uv_tool_with_packages(distribution_name=distribution_name)
for package in with_packages:
cmd += f" {shlex.quote(package)}"
if _resolve_include_prereleases(include_prereleases):
cmd += " --prerelease allow"
return cmd
def install_package_command(
@@ -0,0 +1,184 @@
"""Confirmation modals for `/update` dependency-refresh flows in the TUI.
When `deepagents-code` itself is already on the latest release, `/update` can
still re-resolve its dependencies to the newest versions allowed by the pinned
ranges (e.g. a new `langchain-openai`). The already-current path dry-runs the
resolution first, then asks for explicit confirmation only when dependencies can
move. `/update --deps` skips that prompt, but asks before taking an available
app update ahead of the dependency refresh for the current app version.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from textual.binding import Binding, BindingType
from textual.containers import Vertical
from textual.screen import ModalScreen
from textual.widgets import Static
if TYPE_CHECKING:
from textual.app import ComposeResult
class _DependencyConfirmScreen(ModalScreen[bool]):
"""Shared base for the `/update` dependency-refresh confirmation modals.
Subclasses supply only their bindings and the title/body/help text; the base
owns the layout, styling, and the `True`/`False` dismissal contract. The
`DEFAULT_CSS` type selector matches subclasses because Textual resolves type
selectors against every class name in the MRO.
"""
DEFAULT_CSS = """
_DependencyConfirmScreen {
align: center middle;
}
_DependencyConfirmScreen > Vertical {
width: 66;
max-width: 90%;
height: auto;
background: $surface;
border: solid $primary;
padding: 1 2;
}
_DependencyConfirmScreen .dependency-confirm-title {
text-style: bold;
color: $primary;
text-align: center;
margin-bottom: 1;
}
_DependencyConfirmScreen .dependency-confirm-body {
height: auto;
color: $text;
margin-bottom: 1;
}
_DependencyConfirmScreen .dependency-confirm-help {
height: 1;
color: $text-muted;
text-style: italic;
text-align: center;
}
"""
def __init__(self, *, title: str, body: str, help_text: str) -> None:
"""Store the dialog copy for `compose`.
Args:
title: Bold heading shown at the top of the dialog.
body: Explanatory paragraph beneath the title.
help_text: Muted key-hint row at the bottom.
"""
super().__init__()
self._title = title
self._body = body
self._help = help_text
def compose(self) -> ComposeResult:
"""Compose the confirmation dialog.
Yields:
Title, body, and help-row widgets parented inside a `Vertical`.
"""
with Vertical():
yield Static(
self._title,
classes="dependency-confirm-title",
markup=False,
)
yield Static(
self._body,
classes="dependency-confirm-body",
markup=False,
)
yield Static(
self._help,
classes="dependency-confirm-help",
markup=False,
)
def action_confirm(self) -> None:
"""Dismiss with `True`."""
self.dismiss(True)
def action_cancel(self) -> None:
"""Dismiss with `False`.
The method name must stay `cancel`: the app owns a priority `escape`
binding that, for an active `ModalScreen`, dispatches to `action_cancel`
if present and otherwise falls through to `dismiss(None)`. Renaming this
would silently regress Esc to a `None` dismiss instead of an explicit
cancel.
"""
self.dismiss(False)
class UpdateBeforeDependenciesConfirmScreen(_DependencyConfirmScreen):
"""Confirmation overlay before `/update --deps` upgrades dcode itself.
Dismisses with `True` when the user chooses the app update first and `False`
when they prefer to refresh dependencies for the current app version.
"""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("enter", "confirm", "Update", show=False, priority=True),
Binding("escape", "cancel", "Refresh deps", show=False, priority=True),
]
def __init__(self, *, current: str, latest: str) -> None:
"""Create the app-update confirmation dialog.
Args:
current: Currently running `deepagents-code` version.
latest: Latest available `deepagents-code` version.
"""
super().__init__(
title="Update dcode first?",
body=(
f"A newer deepagents-code version is available ({current} -> "
f"{latest}). Update dcode now, or refresh dependencies for the "
"current version you already have."
),
help_text="Enter to update dcode, Esc to refresh current dependencies",
)
class RefreshDependenciesConfirmScreen(_DependencyConfirmScreen):
"""Confirmation overlay for a dependency refresh.
Dismisses with `True` when the user confirms and `False` when the user
cancels. Esc is treated as cancel so the user is never forced into a refresh
they did not explicitly choose.
"""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("enter", "confirm", "Refresh", show=False, priority=True),
Binding("escape", "cancel", "Cancel", show=False, priority=True),
]
def __init__(self, *, planned_changes: str | None = None) -> None:
"""Create the dependency-refresh confirmation dialog.
Args:
planned_changes: Optional dry-run summary of dependency updates.
"""
body = (
"deepagents-code is already up to date, but compatible dependency "
"updates are available. Refresh to apply these changes:\n\n"
f"{planned_changes}"
if planned_changes
else (
"deepagents-code is already up to date, but its dependencies "
"can be re-resolved to the newest compatible versions. This may "
"pull in newer minor releases of packages like langchain-openai."
)
)
super().__init__(
title="Refresh dependencies?",
body=body,
help_text="Enter to refresh, Esc to cancel",
)
@@ -5,6 +5,8 @@ from __future__ import annotations
import json
import logging
import os
import shlex
import sys
import time
import tomllib
from collections.abc import Mapping, Sequence # noqa: TC003
@@ -21,17 +23,23 @@ from deepagents_code._version import __version__
from deepagents_code.extras_info import ExtrasIntrospectionError, installed_extra_names
from deepagents_code.update_check import (
CACHE_TTL,
DependencyChange,
InstallMethod,
ToolRequirementIntrospectionError,
_extract_release_times,
_latest_from_releases,
_parse_version,
cleanup_update_logs,
clear_update_notified,
create_update_log_path,
dependency_refresh_command,
dependency_refresh_dry_run_command,
dependency_refresh_supported,
detect_install_method,
editable_extra_hint,
editable_package_hint,
format_age_suffix,
format_dependency_changes,
format_installed_age_suffix,
format_release_age,
format_release_age_parenthetical,
@@ -54,6 +62,9 @@ from deepagents_code.update_check import (
mark_auto_update_default_acknowledged,
mark_update_notified,
mark_version_seen,
parse_dependency_changes,
perform_dependency_refresh,
perform_dependency_refresh_dry_run,
perform_install_extra,
perform_install_package,
perform_upgrade,
@@ -122,6 +133,19 @@ def _write_dist_info(
dist_info.joinpath("METADATA").write_text("\n".join(metadata), encoding="utf-8")
def _write_uv_receipt(
root: Path,
requirements: str,
*,
python: str | None = None,
) -> None:
python_line = f'python = "{python}"\n' if python is not None else ""
root.joinpath("uv-receipt.toml").write_text(
f"[tool]\n{python_line}requirements = [{requirements}]\n",
encoding="utf-8",
)
class TestParseVersion:
def test_basic(self) -> None:
assert _parse_version("1.2.3") == Version("1.2.3")
@@ -1106,6 +1130,335 @@ class TestUpdateLogs:
== "uv tool upgrade deepagents-code --prerelease allow"
)
def test_dependency_refresh_command_pins_current_version(
self, tmp_path, monkeypatch
) -> None:
"""Dependency refresh keeps dcode on the running version."""
_write_uv_receipt(tmp_path, '{ name = "deepagents-code" }')
monkeypatch.setattr("sys.prefix", str(tmp_path))
with patch(
"deepagents_code.extras_info.installed_extra_names",
return_value=frozenset(),
):
assert (
dependency_refresh_command(version="1.2.3")
== "uv tool install -U deepagents-code==1.2.3"
)
def test_dependency_refresh_command_preserves_extras(
self, tmp_path, monkeypatch
) -> None:
"""Dependency refresh must not drop already-installed extras."""
_write_uv_receipt(tmp_path, '{ name = "deepagents-code" }')
monkeypatch.setattr("sys.prefix", str(tmp_path))
with patch(
"deepagents_code.extras_info.installed_extra_names",
return_value=frozenset({"quickjs", "nvidia"}),
):
assert (
dependency_refresh_command(
version="1.2.3",
include_prereleases=True,
)
== "uv tool install -U "
"'deepagents-code[nvidia,quickjs]==1.2.3' --prerelease allow"
)
def test_dependency_refresh_command_preserves_with_packages(
self, tmp_path, monkeypatch
) -> None:
"""Dependency refresh must not drop packages installed via `--with`."""
_write_uv_receipt(
tmp_path,
(
'{ name = "deepagents-code" }, '
'{ name = "langchain-custom" }, '
'{ name = "langchain.another_provider" }'
),
)
monkeypatch.setattr("sys.prefix", str(tmp_path))
with patch(
"deepagents_code.extras_info.installed_extra_names",
return_value=frozenset(),
):
assert dependency_refresh_command(version="1.2.3") == (
"uv tool install -U deepagents-code==1.2.3 "
"--with langchain-custom --with langchain.another_provider"
)
def test_dependency_refresh_command_preserves_uv_python(
self, tmp_path, monkeypatch
) -> None:
"""Dependency refresh must keep uv's recorded interpreter selection."""
_write_uv_receipt(
tmp_path,
'{ name = "deepagents-code" }',
python="3.13",
)
monkeypatch.setattr("sys.prefix", str(tmp_path))
with patch(
"deepagents_code.extras_info.installed_extra_names",
return_value=frozenset(),
):
assert dependency_refresh_command(version="1.2.3") == (
"uv tool install -U --python 3.13 deepagents-code==1.2.3"
)
def test_dependency_refresh_command_quotes_uv_python(
self, tmp_path, monkeypatch
) -> None:
"""Recorded interpreter paths are shell-quoted before execution."""
_write_uv_receipt(
tmp_path,
'{ name = "deepagents-code" }, { name = "langchain-custom" }',
python="/opt/Python 3.13/bin/python",
)
monkeypatch.setattr("sys.prefix", str(tmp_path))
with patch(
"deepagents_code.extras_info.installed_extra_names",
return_value=frozenset(),
):
assert dependency_refresh_command(version="1.2.3") == (
"uv tool install -U --python '/opt/Python 3.13/bin/python' "
"deepagents-code==1.2.3 --with langchain-custom"
)
def test_dependency_refresh_command_refuses_malformed_receipt(
self, tmp_path, monkeypatch
) -> None:
"""Malformed uv receipts must not silently drop `--with` packages."""
tmp_path.joinpath("uv-receipt.toml").write_text(
"[tool\nrequirements = []\n",
encoding="utf-8",
)
monkeypatch.setattr("sys.prefix", str(tmp_path))
with (
patch(
"deepagents_code.extras_info.installed_extra_names",
return_value=frozenset(),
),
pytest.raises(ToolRequirementIntrospectionError, match="Could not read"),
):
dependency_refresh_command(version="1.2.3")
def test_dependency_refresh_command_refuses_unpreservable_with_requirement(
self, tmp_path, monkeypatch
) -> None:
"""Unsupported receipt entries are refused instead of rewritten lossy."""
_write_uv_receipt(
tmp_path,
(
'{ name = "deepagents-code" }, '
'{ name = "langchain-custom", editable = "/tmp/pkg" }'
),
)
monkeypatch.setattr("sys.prefix", str(tmp_path))
with (
patch(
"deepagents_code.extras_info.installed_extra_names",
return_value=frozenset(),
),
pytest.raises(
ToolRequirementIntrospectionError,
match="cannot be preserved automatically",
),
):
dependency_refresh_command(version="1.2.3")
def test_dependency_refresh_dry_run_command_targets_current_python(
self, tmp_path, monkeypatch
) -> None:
"""Dry-run planning resolves against the running tool environment."""
_write_uv_receipt(
tmp_path,
'{ name = "deepagents-code" }, { name = "langchain-custom" }',
)
monkeypatch.setattr("sys.prefix", str(tmp_path))
with patch(
"deepagents_code.extras_info.installed_extra_names",
return_value=frozenset({"quickjs"}),
):
assert dependency_refresh_dry_run_command(
version="1.2.3",
include_prereleases=True,
python="/opt/Dcode Python/bin/python",
) == (
"uv pip install --dry-run --python "
"'/opt/Dcode Python/bin/python' -U "
"'deepagents-code[quickjs]==1.2.3' langchain-custom "
"--prerelease allow"
)
async def test_perform_dependency_refresh_dry_run_uses_pinned_uv_pip_command(
self,
) -> None:
"""Dependency dry run shells out without mutating the tool environment."""
with (
patch(
"deepagents_code.update_check.detect_install_method",
return_value="uv",
),
patch("shutil.which", return_value="/usr/bin/uv"),
patch(
"deepagents_code.extras_info.installed_extra_names",
return_value=frozenset(),
),
patch(
"deepagents_code.update_check._uv_tool_with_packages",
return_value=(),
),
patch(
"deepagents_code.update_check._run_install_subprocess",
new_callable=AsyncMock,
return_value=(True, ""),
) as run_mock,
):
success, _output = await perform_dependency_refresh_dry_run()
assert success is True
run_mock.assert_awaited_once()
await_args = run_mock.await_args
assert await_args is not None
assert await_args.args[0] == (
f"uv pip install --dry-run --python {shlex.quote(sys.executable)} "
f"-U deepagents-code=={__version__}"
)
async def test_perform_dependency_refresh_uses_pinned_uv_command(self) -> None:
"""Dependency refresh shells out without allowing a dcode version bump."""
with (
patch(
"deepagents_code.update_check.detect_install_method",
return_value="uv",
),
patch("shutil.which", return_value="/usr/bin/uv"),
patch(
"deepagents_code.extras_info.installed_extra_names",
return_value=frozenset(),
),
patch(
"deepagents_code.update_check._uv_tool_with_packages",
return_value=(),
),
patch(
"deepagents_code.update_check._uv_tool_python",
return_value=None,
),
patch(
"deepagents_code.update_check._run_install_subprocess",
new_callable=AsyncMock,
return_value=(True, ""),
) as run_mock,
):
success, _output = await perform_dependency_refresh()
assert success is True
run_mock.assert_awaited_once()
await_args = run_mock.await_args
assert await_args is not None
assert await_args.args[0] == (
f"uv tool install -U deepagents-code=={__version__}"
)
async def test_perform_dependency_refresh_reports_with_package_errors(
self,
) -> None:
"""Refresh refuses rather than dropping unknown `--with` packages."""
with (
patch(
"deepagents_code.update_check.detect_install_method",
return_value="uv",
),
patch("shutil.which", return_value="/usr/bin/uv"),
patch(
"deepagents_code.update_check.dependency_refresh_command",
side_effect=ToolRequirementIntrospectionError("receipt broken"),
),
):
success, output = await perform_dependency_refresh()
assert success is False
assert "ToolRequirementIntrospectionError" in output
assert "receipt broken" in output
async def test_perform_dependency_refresh_refuses_brew(self) -> None:
"""Brew cannot refresh deps without taking the app formula update."""
with (
patch(
"deepagents_code.update_check.detect_install_method",
return_value="brew",
),
patch(
"deepagents_code.update_check._run_install_subprocess",
new_callable=AsyncMock,
) as run_mock,
):
success, output = await perform_dependency_refresh()
assert success is False
assert "dependency-only refresh is not supported" in output
run_mock.assert_not_awaited()
async def test_perform_dependency_refresh_refuses_editable(self) -> None:
"""Editable installs can't be re-resolved as a tool environment."""
with (
patch(
"deepagents_code.update_check.detect_install_method",
return_value="unknown",
),
patch(
"deepagents_code.update_check._run_install_subprocess",
new_callable=AsyncMock,
) as run_mock,
):
success, output = await perform_dependency_refresh()
assert success is False
assert "Editable install detected" in output
run_mock.assert_not_awaited()
async def test_perform_dependency_refresh_refuses_other(self) -> None:
"""An unrecognized install method is refused, not guessed at."""
with (
patch(
"deepagents_code.update_check.detect_install_method",
return_value="other",
),
patch(
"deepagents_code.update_check._run_install_subprocess",
new_callable=AsyncMock,
) as run_mock,
):
success, output = await perform_dependency_refresh()
assert success is False
assert "Unsupported install method detected" in output
run_mock.assert_not_awaited()
async def test_perform_dependency_refresh_refuses_when_uv_missing(self) -> None:
"""A uv-managed install still needs `uv` on PATH to refresh."""
with (
patch(
"deepagents_code.update_check.detect_install_method",
return_value="uv",
),
patch("shutil.which", return_value=None),
patch(
"deepagents_code.update_check._run_install_subprocess",
new_callable=AsyncMock,
) as run_mock,
):
success, output = await perform_dependency_refresh()
assert success is False
assert "`uv` not found on PATH." in output
run_mock.assert_not_awaited()
def test_prerelease_upgrade_supported_for_uv(self) -> None:
"""The uv install method can be steered onto the pre-release channel."""
supported, reason = prerelease_upgrade_supported("uv")
@@ -1126,6 +1479,131 @@ class TestUpdateLogs:
assert "aren't supported for this install" in reason
class TestParseDependencyChanges:
"""`parse_dependency_changes` collapses uv's env diff into changes."""
def test_version_bump_pairs_removed_and_added(self) -> None:
"""A `- old` / `+ new` pair for one package becomes one bump entry."""
output = (
"Resolved 120 packages in 12ms\n"
" - langchain-openai==1.3.2\n"
" + langchain-openai==1.5.0\n"
"Installed 1 executable: dcode\n"
)
assert parse_dependency_changes(output) == [
DependencyChange(name="langchain-openai", old="1.3.2", new="1.5.0"),
]
def test_new_package_has_no_old(self) -> None:
"""A lone `+` line is reported as a new package."""
assert parse_dependency_changes(" + httpx==0.28.1\n") == [
DependencyChange(name="httpx", old=None, new="0.28.1"),
]
def test_removed_package_has_no_new(self) -> None:
"""A lone `-` line is reported as a removed package."""
assert parse_dependency_changes(" - httpx==0.28.1\n") == [
DependencyChange(name="httpx", old="0.28.1", new=None),
]
def test_preserves_first_seen_order(self) -> None:
"""Packages keep the order uv first mentioned them in."""
output = " - b-pkg==1.0\n + b-pkg==2.0\n - a-pkg==1.0\n + a-pkg==2.0\n"
names = [change.name for change in parse_dependency_changes(output)]
assert names == ["b-pkg", "a-pkg"]
def test_ignores_non_diff_lines(self) -> None:
"""Resolver chatter without `+`/`-` markers is skipped."""
assert parse_dependency_changes("Resolved 3 packages\nAudited 3\n") == []
class TestFormatDependencyChanges:
"""`format_dependency_changes` renders an aligned summary."""
def test_empty_returns_empty_string(self) -> None:
"""No changes renders to an empty string."""
assert format_dependency_changes([]) == ""
def test_renders_bump_new_and_removed(self) -> None:
"""Each change kind gets its own rendering, column-aligned."""
changes = [
DependencyChange(name="langchain-openai", old="1.3.2", new="1.5.0"),
DependencyChange(name="httpx", old=None, new="0.28.1"),
DependencyChange(name="old-pkg", old="1.0", new=None),
]
rendered = format_dependency_changes(changes)
assert "langchain-openai 1.3.2 -> 1.5.0" in rendered
assert "0.28.1 (new)" in rendered
assert "1.0 (removed)" in rendered
assert "httpx 0.28.1 (new)" in rendered
class TestDependencyChangeKind:
"""`DependencyChange.kind` classifies the three legal shapes."""
def test_bumped_when_both_sides_present(self) -> None:
"""Both `old` and `new` set is an in-place bump."""
assert DependencyChange(name="a", old="1.0", new="2.0").kind == "bumped"
def test_added_when_only_new(self) -> None:
"""Only `new` set is a newly added package."""
assert DependencyChange(name="a", old=None, new="1.0").kind == "added"
def test_removed_when_only_old(self) -> None:
"""Only `old` set is a removed package."""
assert DependencyChange(name="a", old="1.0", new=None).kind == "removed"
def test_empty_shape_is_rejected(self) -> None:
"""`(None, None)` is meaningless and must raise rather than mis-render."""
with pytest.raises(ValueError, match="neither an old nor new version"):
_ = DependencyChange(name="a", old=None, new=None).kind
class TestDependencyChangeAnnotations:
"""`parse_dependency_changes` tolerates uv's source annotations."""
def test_source_annotation_suffix_is_parsed(self) -> None:
"""A non-PyPI source suffix doesn't hide the version change."""
output = (
" - example==0.1.0 (from file:///old)\n"
" + example==0.2.0 (from file:///new)\n"
)
assert parse_dependency_changes(output) == [
DependencyChange(name="example", old="0.1.0", new="0.2.0"),
]
class TestDependencyRefreshSupported:
"""`dependency_refresh_supported` gates the dependency-only refresh."""
def test_uv_is_supported(self) -> None:
"""uv-managed installs can re-resolve dependencies in place."""
supported, reason = dependency_refresh_supported("uv")
assert supported is True
assert reason is None
@pytest.mark.parametrize(
("method", "needle"),
[
("unknown", "Editable install detected"),
("brew", "Homebrew install detected"),
("other", "Unsupported install method detected"),
],
)
def test_non_uv_methods_are_refused_with_reason(
self,
method: InstallMethod,
needle: str,
) -> None:
"""Each non-uv method is refused with a distinct, user-facing reason."""
supported, reason = dependency_refresh_supported(method)
assert supported is False
assert reason is not None
assert needle in reason
class TestInstallExtraCommand:
"""`install_extra_command` builds the uv tool install string."""
@@ -0,0 +1,106 @@
"""Tests for the `/update` dependency-refresh confirmation modal."""
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.widgets import Static
from deepagents_code.widgets.update_confirm import (
RefreshDependenciesConfirmScreen,
UpdateBeforeDependenciesConfirmScreen,
)
class _RefreshConfirmTestApp(App[None]):
def compose(self) -> ComposeResult:
yield Static("base")
class TestRefreshDependenciesConfirmScreen:
"""Behavior tests for `RefreshDependenciesConfirmScreen`."""
async def test_enter_dismisses_with_true(self) -> None:
"""Pressing Enter confirms the refresh."""
app = _RefreshConfirmTestApp()
async with app.run_test() as pilot:
outcomes: list[bool | None] = []
app.push_screen(RefreshDependenciesConfirmScreen(), outcomes.append)
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert outcomes == [True]
async def test_escape_dismisses_with_false(self) -> None:
"""Pressing Esc cancels (no implicit refresh)."""
app = _RefreshConfirmTestApp()
async with app.run_test() as pilot:
outcomes: list[bool | None] = []
app.push_screen(RefreshDependenciesConfirmScreen(), outcomes.append)
await pilot.pause()
await pilot.press("escape")
await pilot.pause()
assert outcomes == [False]
async def test_action_cancel_dismisses_with_false(self) -> None:
"""`action_cancel` cancels — the path taken by the app's Esc handler."""
app = _RefreshConfirmTestApp()
async with app.run_test() as pilot:
outcomes: list[bool | None] = []
screen = RefreshDependenciesConfirmScreen()
app.push_screen(screen, outcomes.append)
await pilot.pause()
screen.action_cancel()
await pilot.pause()
assert outcomes == [False]
async def test_planned_changes_are_displayed(self) -> None:
"""Dry-run dependency changes are shown before confirmation."""
app = _RefreshConfirmTestApp()
async with app.run_test() as pilot:
app.push_screen(
RefreshDependenciesConfirmScreen(
planned_changes=" langchain-openai 1.3.2 -> 1.5.0",
),
)
await pilot.pause()
text = "\n".join(str(widget.content) for widget in app.screen.query(Static))
assert "compatible dependency updates are available" in text
assert "langchain-openai 1.3.2 -> 1.5.0" in text
class TestUpdateBeforeDependenciesConfirmScreen:
"""Behavior tests for `UpdateBeforeDependenciesConfirmScreen`."""
async def test_enter_dismisses_with_true(self) -> None:
"""Pressing Enter chooses the app update first."""
app = _RefreshConfirmTestApp()
async with app.run_test() as pilot:
outcomes: list[bool | None] = []
app.push_screen(
UpdateBeforeDependenciesConfirmScreen(
current="1.0.0",
latest="1.1.0",
),
outcomes.append,
)
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert outcomes == [True]
async def test_escape_dismisses_with_false(self) -> None:
"""Pressing Esc keeps dcode current and refreshes dependencies."""
app = _RefreshConfirmTestApp()
async with app.run_test() as pilot:
outcomes: list[bool | None] = []
app.push_screen(
UpdateBeforeDependenciesConfirmScreen(
current="1.0.0",
latest="1.1.0",
),
outcomes.append,
)
await pilot.pause()
await pilot.press("escape")
await pilot.pause()
assert outcomes == [False]
+651 -1
View File
@@ -2,13 +2,14 @@
from __future__ import annotations
import asyncio
import subprocess
import sys
import tomllib
from importlib.metadata import version as pkg_version
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, Mock, patch
import pytest
@@ -601,6 +602,655 @@ async def test_update_slash_command_rejects_unknown_option() -> None:
)
async def test_update_deps_refreshes_when_dcode_current() -> None:
"""`/update --deps` re-resolves deps after confirming dcode is current."""
from deepagents_code.app import DeepAgentsApp
from deepagents_code.widgets.messages import AppMessage
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=(False, "1.0.0"),
) as is_update_mock,
patch(
"deepagents_code.update_check.perform_dependency_refresh",
new_callable=AsyncMock,
return_value=(
True,
" - langchain-openai==1.3.2\n + langchain-openai==1.5.0\n",
),
) as refresh_mock,
):
await app._handle_command("/update --deps")
await pilot.pause()
is_update_mock.assert_called_once_with(
bypass_cache=True,
include_prereleases=None,
)
refresh_mock.assert_awaited_once_with(include_prereleases=None)
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
content = str(app_msgs[-1]._content)
assert "Refreshed dependencies:" in content
assert "langchain-openai 1.3.2 -> 1.5.0" in content
async def test_update_deps_reports_when_already_current() -> None:
"""`/update --deps` reports when nothing changed."""
from deepagents_code.app import DeepAgentsApp
from deepagents_code.widgets.messages import AppMessage
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=(False, "1.0.0"),
),
patch(
"deepagents_code.update_check.perform_dependency_refresh",
new_callable=AsyncMock,
return_value=(True, "Resolved 120 packages in 12ms\n"),
),
):
await app._handle_command("/update --deps")
await pilot.pause()
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
assert "Dependencies are already up to date." in str(app_msgs[-1]._content)
async def test_update_deps_routes_outdated_dcode_through_regular_update() -> None:
"""`/update --deps` runs the normal update flow when dcode is outdated."""
from deepagents_code.app import DeepAgentsApp
from deepagents_code.widgets.messages import AppMessage
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, "1.1.0"),
),
patch.object(
DeepAgentsApp,
"_confirm_update_before_dependency_refresh",
new_callable=AsyncMock,
return_value=True,
),
patch(
"deepagents_code.update_check.perform_upgrade",
new_callable=AsyncMock,
return_value=(
True,
" - deepagents-code==1.0.0\n + deepagents-code==1.1.0\n",
),
),
):
await app._handle_command("/update --deps")
await pilot.pause()
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
content = str(app_msgs[-1]._content)
assert "Updated to v1.1.0" in content
assert "Quit and relaunch dcode to use the new version" in content
assert "Updated deepagents-code:" not in content
assert "Dependencies are already up to date." not in content
async def test_update_deps_skips_refresh_prompt_when_refresh_unsupported() -> None:
"""Unsupported refresh installs take the normal outdated dcode update path."""
from deepagents_code.app import DeepAgentsApp
from deepagents_code.widgets.messages import AppMessage
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, "1.1.0"),
),
patch(
"deepagents_code.update_check.dependency_refresh_supported",
return_value=(False, "Homebrew install detected — ..."),
),
patch.object(
DeepAgentsApp,
"_confirm_update_before_dependency_refresh",
new_callable=AsyncMock,
) as confirm_mock,
patch(
"deepagents_code.update_check.perform_dependency_refresh",
new_callable=AsyncMock,
) as refresh_mock,
patch(
"deepagents_code.update_check.perform_upgrade",
new_callable=AsyncMock,
return_value=(
True,
" - deepagents-code==1.0.0\n + deepagents-code==1.1.0\n",
),
) as perform_upgrade_mock,
):
await app._handle_command("/update --deps")
await pilot.pause()
confirm_mock.assert_not_awaited()
refresh_mock.assert_not_awaited()
perform_upgrade_mock.assert_awaited_once_with(include_prereleases=None)
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
content = str(app_msgs[-1]._content)
assert "Updated to v1.1.0" in content
assert "Updated deepagents-code:" not in content
assert "Dependency refresh failed" not in content
async def test_update_deps_decline_app_update_refreshes_current_deps() -> None:
"""Declining the app update refreshes deps without upgrading dcode."""
from deepagents_code.app import DeepAgentsApp
from deepagents_code.widgets.messages import AppMessage
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, "1.1.0"),
),
patch(
"deepagents_code.update_check.dependency_refresh_supported",
return_value=(True, None),
),
patch.object(
DeepAgentsApp,
"_confirm_update_before_dependency_refresh",
new_callable=AsyncMock,
return_value=False,
),
patch(
"deepagents_code.update_check.perform_dependency_refresh",
new_callable=AsyncMock,
return_value=(
True,
" - langchain-openai==1.3.2\n + langchain-openai==1.5.0\n",
),
) as refresh_mock,
patch(
"deepagents_code.update_check.perform_upgrade",
new_callable=AsyncMock,
) as perform_upgrade_mock,
):
await app._handle_command("/update --deps")
await pilot.pause()
refresh_mock.assert_awaited_once_with(include_prereleases=None)
perform_upgrade_mock.assert_not_awaited()
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
content = str(app_msgs[-1]._content)
assert "Refreshed dependencies:" in content
assert "langchain-openai 1.3.2 -> 1.5.0" in content
assert "A deepagents-code update is available: v1.1.0." in content
assert "Updated to v1.1.0" not in content
async def test_update_deps_decline_app_update_reports_no_new_deps() -> None:
"""`/update --deps` reports current deps even when dcode has an update."""
from deepagents_code.app import DeepAgentsApp
from deepagents_code.widgets.messages import AppMessage
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, "1.1.0"),
),
patch(
"deepagents_code.update_check.dependency_refresh_supported",
return_value=(True, None),
),
patch.object(
DeepAgentsApp,
"_confirm_update_before_dependency_refresh",
new_callable=AsyncMock,
return_value=False,
),
patch(
"deepagents_code.update_check.perform_dependency_refresh",
new_callable=AsyncMock,
return_value=(True, "Resolved 120 packages in 12ms\n"),
),
patch(
"deepagents_code.update_check.perform_upgrade",
new_callable=AsyncMock,
) as perform_upgrade_mock,
):
await app._handle_command("/update --deps")
await pilot.pause()
perform_upgrade_mock.assert_not_awaited()
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
content = str(app_msgs[-1]._content)
assert "Dependencies are already up to date." in content
assert "A deepagents-code update is available: v1.1.0." in content
assert "Updated to v1.1.0" not in content
async def test_update_already_current_prompts_and_refreshes_on_confirm() -> None:
"""Plain `/update` offers a dep refresh when dcode is already current."""
from deepagents_code.app import DeepAgentsApp
from deepagents_code.widgets.messages import AppMessage
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=(False, "1.0.0"),
),
patch(
"deepagents_code.update_check.dependency_refresh_supported",
return_value=(True, None),
),
patch(
"deepagents_code.update_check.perform_dependency_refresh_dry_run",
new_callable=AsyncMock,
return_value=(
True,
" - langchain-openai==1.3.2\n + langchain-openai==1.5.0\n",
),
) as dry_run_mock,
patch.object(
DeepAgentsApp,
"_confirm_refresh_dependencies",
new_callable=AsyncMock,
return_value=True,
) as confirm_mock,
patch(
"deepagents_code.update_check.perform_dependency_refresh",
new_callable=AsyncMock,
return_value=(
True,
" - langchain-openai==1.3.2\n + langchain-openai==1.5.0\n",
),
) as refresh_mock,
):
await app._handle_command("/update")
await pilot.pause()
dry_run_mock.assert_awaited_once_with(include_prereleases=None)
confirm_mock.assert_awaited_once_with(
planned_changes=" langchain-openai 1.3.2 -> 1.5.0",
)
refresh_mock.assert_awaited_once_with(include_prereleases=None)
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
assert "Refreshed dependencies:" in str(app_msgs[-1]._content)
async def test_update_already_current_skips_refresh_on_decline() -> None:
"""Declining the prompt leaves the install untouched."""
from deepagents_code.app import DeepAgentsApp
from deepagents_code.widgets.messages import AppMessage
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=(False, "1.0.0"),
),
patch(
"deepagents_code.update_check.dependency_refresh_supported",
return_value=(True, None),
),
patch(
"deepagents_code.update_check.perform_dependency_refresh_dry_run",
new_callable=AsyncMock,
return_value=(
True,
" - langchain-openai==1.3.2\n + langchain-openai==1.5.0\n",
),
) as dry_run_mock,
patch.object(
DeepAgentsApp,
"_confirm_refresh_dependencies",
new_callable=AsyncMock,
return_value=False,
) as confirm_mock,
patch(
"deepagents_code.update_check.perform_dependency_refresh",
new_callable=AsyncMock,
) as refresh_mock,
):
await app._handle_command("/update")
await pilot.pause()
dry_run_mock.assert_awaited_once_with(include_prereleases=None)
confirm_mock.assert_awaited_once_with(
planned_changes=" langchain-openai 1.3.2 -> 1.5.0",
)
refresh_mock.assert_not_awaited()
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
assert "Dependency refresh skipped." in str(app_msgs[-1]._content)
async def test_update_already_current_reports_no_dependency_changes() -> None:
"""Plain `/update` skips the prompt when the dry run finds no changes."""
from deepagents_code.app import DeepAgentsApp
from deepagents_code.widgets.messages import AppMessage
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=(False, "1.0.0"),
),
patch(
"deepagents_code.update_check.dependency_refresh_supported",
return_value=(True, None),
),
patch(
"deepagents_code.update_check.perform_dependency_refresh_dry_run",
new_callable=AsyncMock,
return_value=(True, "Resolved 120 packages in 12ms\n"),
) as dry_run_mock,
patch.object(
DeepAgentsApp,
"_confirm_refresh_dependencies",
new_callable=AsyncMock,
) as confirm_mock,
patch(
"deepagents_code.update_check.perform_dependency_refresh",
new_callable=AsyncMock,
) as refresh_mock,
):
await app._handle_command("/update")
await pilot.pause()
dry_run_mock.assert_awaited_once_with(include_prereleases=None)
confirm_mock.assert_not_awaited()
refresh_mock.assert_not_awaited()
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
assert "Dependencies are already up to date." in str(app_msgs[-1]._content)
async def test_update_already_current_reports_dependency_check_failure() -> None:
"""A failed dry-run check reports the failure without refreshing."""
from deepagents_code.app import DeepAgentsApp
from deepagents_code.widgets.messages import AppMessage
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=(False, "1.0.0"),
),
patch(
"deepagents_code.update_check.dependency_refresh_supported",
return_value=(True, None),
),
patch(
"deepagents_code.update_check.perform_dependency_refresh_dry_run",
new_callable=AsyncMock,
return_value=(False, "No solution found"),
),
patch.object(
DeepAgentsApp,
"_confirm_refresh_dependencies",
new_callable=AsyncMock,
) as confirm_mock,
patch(
"deepagents_code.update_check.perform_dependency_refresh",
new_callable=AsyncMock,
) as refresh_mock,
):
await app._handle_command("/update")
await pilot.pause()
confirm_mock.assert_not_awaited()
refresh_mock.assert_not_awaited()
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
content = str(app_msgs[-1]._content)
assert "Could not check dependency updates" in content
assert "No solution found" in content
async def test_update_already_current_skips_prompt_when_refresh_unsupported() -> None:
"""brew/other installs aren't prompted for a refresh that can't run."""
from deepagents_code.app import DeepAgentsApp
from deepagents_code.widgets.messages import AppMessage
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=(False, "1.0.0"),
),
patch(
"deepagents_code.update_check.dependency_refresh_supported",
return_value=(False, "Homebrew install detected — ..."),
),
patch.object(
DeepAgentsApp,
"_confirm_refresh_dependencies",
new_callable=AsyncMock,
) as confirm_mock,
patch(
"deepagents_code.update_check.perform_dependency_refresh",
new_callable=AsyncMock,
) as refresh_mock,
):
await app._handle_command("/update")
await pilot.pause()
confirm_mock.assert_not_awaited()
refresh_mock.assert_not_awaited()
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
assert "Already on the latest version" in str(app_msgs[-1]._content)
async def test_refresh_dependencies_surfaces_failure() -> None:
"""A failed refresh reports the start of uv's output, not silence."""
from deepagents_code.app import DeepAgentsApp
from deepagents_code.widgets.messages import AppMessage
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
with patch(
"deepagents_code.update_check.perform_dependency_refresh",
new_callable=AsyncMock,
return_value=(False, "No solution found for langchain-openai" + "x" * 400),
):
await app._refresh_dependencies(include_prereleases=None)
await pilot.pause()
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
content = str(app_msgs[-1]._content)
assert "Dependency refresh failed" in content
assert "No solution found" in content
async def test_refresh_dependencies_renders_self_changes() -> None:
"""A `deepagents-code` line in the diff renders under its own heading."""
from deepagents_code.app import DeepAgentsApp
from deepagents_code.widgets.messages import AppMessage
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
with patch(
"deepagents_code.update_check.perform_dependency_refresh",
new_callable=AsyncMock,
return_value=(
True,
(
" - deepagents-code==1.0.0\n + deepagents-code==1.0.1\n"
" - langchain-openai==1.3.2\n + langchain-openai==1.5.0\n"
),
),
):
await app._refresh_dependencies(include_prereleases=None)
await pilot.pause()
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
content = str(app_msgs[-1]._content)
assert "Updated deepagents-code:" in content
assert "Refreshed dependencies:" in content
assert "langchain-openai 1.3.2 -> 1.5.0" in content
async def test_refresh_dependencies_skips_in_debug_mode(monkeypatch) -> None:
"""`DEBUG_UPDATE` short-circuits before shelling out to uv."""
from deepagents_code._env_vars import DEBUG_UPDATE
from deepagents_code.app import DeepAgentsApp
from deepagents_code.widgets.messages import AppMessage
monkeypatch.setenv(DEBUG_UPDATE, "1")
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
with patch(
"deepagents_code.update_check.perform_dependency_refresh",
new_callable=AsyncMock,
) as refresh_mock:
await app._refresh_dependencies(include_prereleases=None)
await pilot.pause()
refresh_mock.assert_not_awaited()
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
assert "Skipped dependency refresh (debug mode)." in str(app_msgs[-1]._content)
async def test_confirm_refresh_dependencies_reports_mount_failure() -> None:
"""A modal that fails to mount is surfaced, not silently swallowed."""
from deepagents_code.app import DeepAgentsApp
from deepagents_code.widgets.messages import AppMessage
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
with patch.object(
DeepAgentsApp,
"_push_screen_wait",
Mock(side_effect=RuntimeError("boom")),
):
result = await app._confirm_refresh_dependencies()
await pilot.pause()
assert result is False
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
assert "Couldn't show the dependency-refresh prompt" in str(
app_msgs[-1]._content
)
async def test_confirm_refresh_dependencies_reports_timeout(monkeypatch) -> None:
"""A modal that never resolves is bounded by the watchdog and surfaced."""
from deepagents_code import app as app_module
from deepagents_code.app import DeepAgentsApp
from deepagents_code.widgets.messages import AppMessage
async def _never(_self: object, _screen: object) -> None:
await asyncio.sleep(10)
monkeypatch.setattr(app_module, "_MODAL_WATCHDOG_TIMEOUT_SECONDS", 0.01)
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
with patch.object(DeepAgentsApp, "_push_screen_wait", _never):
result = await app._confirm_refresh_dependencies()
await pilot.pause()
assert result is False
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
assert "timed out" in str(app_msgs[-1]._content)
async def test_confirm_update_before_dependency_refresh_reports_mount_failure() -> None:
"""A failed app-update prompt falls back to refreshing current deps."""
from deepagents_code.app import DeepAgentsApp
from deepagents_code.widgets.messages import AppMessage
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
with patch.object(
DeepAgentsApp,
"_push_screen_wait",
Mock(side_effect=RuntimeError("boom")),
):
result = await app._confirm_update_before_dependency_refresh(
current="1.0.0",
latest="1.1.0",
)
await pilot.pause()
assert result is False
app_msgs = [m for m in app.query(AppMessage) if not m._is_markdown]
assert "Couldn't show the update prompt" in str(app_msgs[-1]._content)
def test_help_mentions_version_flag() -> None:
"""Verify that the CLI help text mentions `--version` and SDK."""
result = subprocess.run(